@hyperframes/studio 0.7.84 → 0.7.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.84",
3
+ "version": "0.7.86",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.84",
50
- "@hyperframes/player": "0.7.84",
51
- "@hyperframes/sdk": "0.7.84",
52
- "@hyperframes/parsers": "0.7.84",
53
- "@hyperframes/studio-server": "0.7.84"
49
+ "@hyperframes/core": "0.7.86",
50
+ "@hyperframes/parsers": "0.7.86",
51
+ "@hyperframes/player": "0.7.86",
52
+ "@hyperframes/sdk": "0.7.86",
53
+ "@hyperframes/studio-server": "0.7.86"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.84"
68
+ "@hyperframes/producer": "0.7.86"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -36,6 +36,7 @@ import { useTrackGapMenu } from "./useTrackGapMenu";
36
36
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
37
37
  import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
38
38
  import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
39
+ import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
39
40
 
40
41
  // Re-export pure utilities so existing imports from "./Timeline" still resolve.
41
42
  export {
@@ -276,6 +277,11 @@ export const Timeline = memo(function Timeline({
276
277
  });
277
278
 
278
279
  const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
280
+ const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
281
+ totalClipCount: expandedElements.length,
282
+ totalRowCount: displayLayout.displayTrackOrder.length,
283
+ zoomMode,
284
+ });
279
285
  const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
280
286
  timelineReady,
281
287
  expandedElements.length,
@@ -469,6 +475,7 @@ export const Timeline = memo(function Timeline({
469
475
  className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
470
476
  onScroll={(e) => {
471
477
  lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
478
+ recordTimelineScroll(e.currentTarget);
472
479
  }}
473
480
  onDragOver={handleAssetDragOver}
474
481
  onDragLeave={() => clearDropPreview()}
@@ -0,0 +1,120 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, createElement } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const trackStudioTimelinePerformance = vi.hoisted(() => vi.fn());
8
+
9
+ vi.mock("../../telemetry/events", () => ({
10
+ trackStudioTimelinePerformance,
11
+ }));
12
+
13
+ import {
14
+ summarizeTimelinePerformance,
15
+ useTimelinePerformanceTelemetry,
16
+ } from "./useTimelinePerformanceTelemetry";
17
+
18
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
19
+
20
+ afterEach(() => {
21
+ trackStudioTimelinePerformance.mockReset();
22
+ vi.useRealTimers();
23
+ vi.restoreAllMocks();
24
+ vi.unstubAllGlobals();
25
+ });
26
+
27
+ describe("summarizeTimelinePerformance", () => {
28
+ it("reports raw mounted work and p95 scroll timings", () => {
29
+ const scroll = document.createElement("div");
30
+ Object.defineProperties(scroll, {
31
+ clientWidth: { value: 1_200 },
32
+ clientHeight: { value: 360 },
33
+ });
34
+ scroll.innerHTML = `
35
+ <div>
36
+ <div data-clip="true"></div>
37
+ <div data-clip="true"><span></span></div>
38
+ </div>
39
+ `;
40
+
41
+ expect(
42
+ summarizeTimelinePerformance(
43
+ scroll,
44
+ { totalClipCount: 3_000, totalRowCount: 24, zoomMode: "fit" },
45
+ [8, 10, 12, 80],
46
+ [16, 17, 45],
47
+ ),
48
+ ).toEqual({
49
+ total_clip_count: 3_000,
50
+ mounted_clip_count: 2,
51
+ total_row_count: 24,
52
+ timeline_dom_node_count: 4,
53
+ viewport_width: 1_200,
54
+ viewport_height: 360,
55
+ zoom_mode: "fit",
56
+ scroll_sample_count: 4,
57
+ scroll_frame_latency_p95_ms: 80,
58
+ scroll_frame_latency_max_ms: 80,
59
+ frame_interval_p95_ms: 45,
60
+ });
61
+ });
62
+
63
+ it("does not emit a summary without a completed animation frame", () => {
64
+ const scroll = document.createElement("div");
65
+
66
+ expect(
67
+ summarizeTimelinePerformance(
68
+ scroll,
69
+ { totalClipCount: 1, totalRowCount: 1, zoomMode: "manual" },
70
+ [],
71
+ [],
72
+ ),
73
+ ).toBeNull();
74
+ });
75
+ });
76
+
77
+ describe("useTimelinePerformanceTelemetry", () => {
78
+ it("measures callback delivery when the animation-frame timestamp predates the scroll", () => {
79
+ vi.useFakeTimers();
80
+ let frameCallback: FrameRequestCallback | undefined;
81
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
82
+ frameCallback = callback;
83
+ return 1;
84
+ });
85
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
86
+
87
+ let recordTimelineScroll: ((scroll: HTMLDivElement) => void) | undefined;
88
+ function Probe() {
89
+ recordTimelineScroll = useTimelinePerformanceTelemetry({
90
+ totalClipCount: 1,
91
+ totalRowCount: 1,
92
+ zoomMode: "manual",
93
+ }).recordTimelineScroll;
94
+ return null;
95
+ }
96
+
97
+ const root = createRoot(document.createElement("div"));
98
+ act(() => root.render(createElement(Probe)));
99
+
100
+ try {
101
+ vi.spyOn(performance, "now")
102
+ .mockReturnValueOnce(100)
103
+ .mockReturnValueOnce(108)
104
+ .mockReturnValueOnce(500);
105
+
106
+ recordTimelineScroll?.(document.createElement("div"));
107
+ frameCallback?.(99);
108
+ vi.advanceTimersByTime(400);
109
+
110
+ expect(trackStudioTimelinePerformance).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ scroll_frame_latency_p95_ms: 8,
113
+ scroll_frame_latency_max_ms: 8,
114
+ }),
115
+ );
116
+ } finally {
117
+ act(() => root.unmount());
118
+ }
119
+ });
120
+ });
@@ -0,0 +1,132 @@
1
+ import { useRef } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import {
4
+ trackStudioTimelinePerformance,
5
+ type StudioTimelinePerformanceSample,
6
+ } from "../../telemetry/events";
7
+
8
+ const SCROLL_IDLE_MS = 400;
9
+ const MIN_EVENT_INTERVAL_MS = 60_000;
10
+
11
+ interface TimelinePerformanceState {
12
+ frameRequest: number;
13
+ idleTimer: ReturnType<typeof setTimeout> | null;
14
+ pendingScrollStartedAt: number;
15
+ previousFrameAt: number | null;
16
+ frameLatencies: number[];
17
+ frameIntervals: number[];
18
+ lastEmittedAt: number;
19
+ }
20
+
21
+ interface TimelinePerformanceContext {
22
+ totalClipCount: number;
23
+ totalRowCount: number;
24
+ zoomMode: string;
25
+ }
26
+
27
+ function percentile(values: readonly number[], fraction: number): number | undefined {
28
+ if (values.length === 0) return undefined;
29
+ const sorted = [...values].sort((a, b) => a - b);
30
+ const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1);
31
+ return Number(sorted[Math.max(0, index)].toFixed(2));
32
+ }
33
+
34
+ export function summarizeTimelinePerformance(
35
+ scroll: HTMLElement,
36
+ context: TimelinePerformanceContext,
37
+ frameLatencies: readonly number[],
38
+ frameIntervals: readonly number[],
39
+ ): StudioTimelinePerformanceSample | null {
40
+ const latencyP95 = percentile(frameLatencies, 0.95);
41
+ if (latencyP95 === undefined) return null;
42
+
43
+ const frameIntervalP95 = percentile(frameIntervals, 0.95);
44
+ return {
45
+ total_clip_count: context.totalClipCount,
46
+ mounted_clip_count: scroll.querySelectorAll('[data-clip="true"]').length,
47
+ total_row_count: context.totalRowCount,
48
+ timeline_dom_node_count: scroll.querySelectorAll("*").length,
49
+ viewport_width: scroll.clientWidth,
50
+ viewport_height: scroll.clientHeight,
51
+ zoom_mode: context.zoomMode,
52
+ scroll_sample_count: frameLatencies.length,
53
+ scroll_frame_latency_p95_ms: latencyP95,
54
+ scroll_frame_latency_max_ms: Number(Math.max(...frameLatencies).toFixed(2)),
55
+ ...(frameIntervalP95 === undefined ? {} : { frame_interval_p95_ms: frameIntervalP95 }),
56
+ };
57
+ }
58
+
59
+ function resetMeasurements(state: TimelinePerformanceState): void {
60
+ state.previousFrameAt = null;
61
+ state.frameLatencies = [];
62
+ state.frameIntervals = [];
63
+ }
64
+
65
+ /**
66
+ * Samples one event per timeline scroll burst, capped at one event per minute.
67
+ * The event contains only aggregate counts and timings—never project content.
68
+ */
69
+ export function useTimelinePerformanceTelemetry(context: TimelinePerformanceContext): {
70
+ recordTimelineScroll: (scroll: HTMLDivElement) => void;
71
+ } {
72
+ const stateRef = useRef<TimelinePerformanceState>({
73
+ frameRequest: 0,
74
+ idleTimer: null,
75
+ pendingScrollStartedAt: 0,
76
+ previousFrameAt: null,
77
+ frameLatencies: [],
78
+ frameIntervals: [],
79
+ lastEmittedAt: Number.NEGATIVE_INFINITY,
80
+ });
81
+
82
+ const recordTimelineScroll = (scroll: HTMLDivElement) => {
83
+ const state = stateRef.current;
84
+ const now = performance.now();
85
+
86
+ if (state.frameRequest === 0) {
87
+ state.pendingScrollStartedAt = now;
88
+ state.frameRequest = requestAnimationFrame((frameAt) => {
89
+ state.frameRequest = 0;
90
+ state.frameLatencies.push(performance.now() - state.pendingScrollStartedAt);
91
+ if (state.previousFrameAt !== null) {
92
+ state.frameIntervals.push(frameAt - state.previousFrameAt);
93
+ }
94
+ state.previousFrameAt = frameAt;
95
+ });
96
+ }
97
+
98
+ if (state.idleTimer !== null) clearTimeout(state.idleTimer);
99
+ state.idleTimer = setTimeout(() => {
100
+ state.idleTimer = null;
101
+ if (state.frameRequest !== 0) {
102
+ cancelAnimationFrame(state.frameRequest);
103
+ state.frameRequest = 0;
104
+ resetMeasurements(state);
105
+ return;
106
+ }
107
+
108
+ const emittedAt = performance.now();
109
+ if (emittedAt - state.lastEmittedAt >= MIN_EVENT_INTERVAL_MS) {
110
+ const sample = summarizeTimelinePerformance(
111
+ scroll,
112
+ context,
113
+ state.frameLatencies,
114
+ state.frameIntervals,
115
+ );
116
+ if (sample) {
117
+ trackStudioTimelinePerformance(sample);
118
+ state.lastEmittedAt = emittedAt;
119
+ }
120
+ }
121
+ resetMeasurements(state);
122
+ }, SCROLL_IDLE_MS);
123
+ };
124
+
125
+ useMountEffect(() => () => {
126
+ const state = stateRef.current;
127
+ if (state.frameRequest !== 0) cancelAnimationFrame(state.frameRequest);
128
+ if (state.idleTimer !== null) clearTimeout(state.idleTimer);
129
+ });
130
+
131
+ return { recordTimelineScroll };
132
+ }
@@ -15,6 +15,7 @@ const {
15
15
  trackStudioKeyframeLaneExpand,
16
16
  trackStudioSegmentEaseEdit,
17
17
  trackStudioFeedback,
18
+ trackStudioTimelinePerformance,
18
19
  } = await import("./events");
19
20
 
20
21
  describe("studio telemetry events", () => {
@@ -63,6 +64,26 @@ describe("studio telemetry events", () => {
63
64
  });
64
65
  });
65
66
 
67
+ it("trackStudioTimelinePerformance emits raw timeline measurements", () => {
68
+ const sample = {
69
+ total_clip_count: 3_000,
70
+ mounted_clip_count: 160,
71
+ total_row_count: 24,
72
+ timeline_dom_node_count: 957,
73
+ viewport_width: 1_200,
74
+ viewport_height: 360,
75
+ zoom_mode: "fit",
76
+ scroll_sample_count: 20,
77
+ scroll_frame_latency_p95_ms: 24.5,
78
+ scroll_frame_latency_max_ms: 31.2,
79
+ frame_interval_p95_ms: 42.3,
80
+ };
81
+
82
+ trackStudioTimelinePerformance(sample);
83
+
84
+ expect(trackEvent).toHaveBeenCalledWith("studio_timeline_performance", sample);
85
+ });
86
+
66
87
  it("trackStudioRazorSplit emits 'studio_razor_split' with mode and count", () => {
67
88
  trackStudioRazorSplit({ mode: "all", count: 3 });
68
89
  expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 });
@@ -26,6 +26,24 @@ export function trackStudioRenderStart(props: {
26
26
  });
27
27
  }
28
28
 
29
+ export type StudioTimelinePerformanceSample = {
30
+ total_clip_count: number;
31
+ mounted_clip_count: number;
32
+ total_row_count: number;
33
+ timeline_dom_node_count: number;
34
+ viewport_width: number;
35
+ viewport_height: number;
36
+ zoom_mode: string;
37
+ scroll_sample_count: number;
38
+ scroll_frame_latency_p95_ms: number;
39
+ scroll_frame_latency_max_ms: number;
40
+ frame_interval_p95_ms?: number;
41
+ };
42
+
43
+ export function trackStudioTimelinePerformance(props: StudioTimelinePerformanceSample): void {
44
+ trackEvent("studio_timeline_performance", props);
45
+ }
46
+
29
47
  function getBrowserDoctorSummary(): string {
30
48
  try {
31
49
  const nav = navigator as Navigator & {