feat(studio): track preview time to first frame (#4226)

This commit is contained in:
Miguel Ángel
2026-09-20 17:10:41 -04:00
committed by GitHub
parent f476e78cc8
commit e48eac7f5e
7 changed files with 125 additions and 2 deletions
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTelemetry";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -178,6 +179,13 @@ describe("resolvePreviewStageSize", () => {
});
describe("NLEPreview", () => {
it("counts timeline clips and media clips from the painted document", () => {
const doc = new DOMParser().parseFromString(
'<div data-composition-id="main" data-duration="10"><video data-start="0" data-duration="2"></video><audio data-start="2" data-duration="3"></audio><div data-start="5" data-duration="1"></div></div>',
"text/html",
);
expect(readPreviewComplexity(doc)).toEqual({ clip_count: 3, media_clip_count: 2 });
});
beforeEach(() => {
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
});
@@ -18,6 +18,7 @@ import {
} from "./previewZoom";
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
interface NLEPreviewProps {
projectId: string;
iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -147,6 +148,7 @@ export const NLEPreview = memo(function NLEPreview({
}, [activeKey, resetPreviewSlots]);
const liveGenRef = useRef<number | null>(null);
const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
@@ -505,6 +507,7 @@ export const NLEPreview = memo(function NLEPreview({
applyInitialZoom();
}}
onCompositionLoadingChange={onCompositionLoadingChange}
onPainted={(details) => reportPreviewFirstFrame(slot, details)}
portrait={portrait}
suppressLoadingOverlay={suppressLoadingOverlay}
style={
@@ -521,6 +524,7 @@ export const NLEPreview = memo(function NLEPreview({
directUrl={slot.url}
onLoad={() => onShadowIframeLoad(slot.gen)}
onReadyToShowChange={(ready) => onShadowReadyChange(slot.gen, ready)}
onPainted={(details) => reportPreviewFirstFrame(slot, details)}
onPreviewError={(message) => onShadowError(slot.gen, message)}
portrait={portrait}
suppressLoadingOverlay
@@ -143,7 +143,10 @@ describe("preview errors", () => {
});
it("retries a failed preview with a fresh player URL", async () => {
const { host, player } = await mountPlayer();
const onPainted = vi.fn();
const { host, player } = await mountPlayer({ onPainted });
act(() => void player.dispatchEvent(new Event("painted")));
act(() => {
player.dispatchEvent(
@@ -164,6 +167,9 @@ describe("preview errors", () => {
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
act(() => void player.dispatchEvent(new Event("painted")));
expect(onPainted.mock.calls.map(([details]) => details.loadId)).toEqual([1, 2]);
});
});
@@ -231,6 +237,21 @@ describe("ready to show", () => {
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
});
it("reports the document start time with the painted iframe", async () => {
const onPainted = vi.fn();
const now = vi.spyOn(performance, "now").mockReturnValue(100);
const { player } = await mountPlayer({ onPainted });
painted(player as TestHyperframesPlayer);
expect(onPainted).toHaveBeenCalledWith({
iframe: (player as TestHyperframesPlayer).iframeElement,
startedAt: 100,
loadId: 1,
});
now.mockRestore();
});
it("holds while the shader transition loader is up and fires once it clears", async () => {
const onReadyToShowChange = vi.fn();
const { player } = await mountPlayer({ onReadyToShowChange });
@@ -30,6 +30,7 @@ interface PlayerProps {
onReadyToShowChange?: (ready: boolean) => void;
onPreviewError?: (message: string) => void;
onCompositionLoadingChange?: (loading: boolean) => void;
onPainted?: (details: { iframe: HTMLIFrameElement; startedAt: number; loadId: number }) => void;
portrait?: boolean;
style?: React.CSSProperties;
suppressLoadingOverlay?: boolean;
@@ -140,6 +141,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
onReadyToShowChange,
onPreviewError,
onCompositionLoadingChange,
onPainted,
portrait,
style,
suppressLoadingOverlay,
@@ -151,6 +153,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryPreviewRef = useRef<(() => void) | null>(null);
const loadStartedAtRef = useRef(0);
const loadIdRef = useRef(0);
const retryCountRef = useRef(0);
// Read at call time: this element outlives its first props (a shadow preview is
// promoted in place), so mount-time closures would go stale.
@@ -209,6 +213,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
setPreviewError(null);
setCompositionLoading(true);
loadStartedAtRef.current = performance.now();
loadIdRef.current += 1;
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
};
retryPreviewRef.current = retryPreview;
@@ -222,7 +228,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
setPreviewError(null);
setCompositionLoading(false);
};
const handlePainted = () => setPainted(true);
const handlePainted = () => {
setPainted(true);
onPainted?.({ iframe, startedAt: loadStartedAtRef.current, loadId: loadIdRef.current });
};
const handleError = (event: Event) => {
const message = readPreviewErrorMessage(event);
onPreviewErrorRef.current?.(message);
@@ -312,6 +321,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
player.style.height = "100%";
player.style.display = "block";
player.style.background = "transparent";
loadStartedAtRef.current = performance.now();
loadIdRef.current += 1;
player.setAttribute("src", src);
container.appendChild(player);
@@ -0,0 +1,51 @@
import { useCallback, useRef } from "react";
import { parseTimelineFromDOM } from "../lib/timelineDOM";
import { readTimelineDurationFromDocument } from "../lib/timelineElementHelpers";
import type { PreviewIframeSlot } from "./useTimelineSyncCallbacks";
import { trackPreviewFirstFrame } from "../../telemetry/events";
import { getBrowserSystemMeta } from "../../telemetry/system";
export function readPreviewComplexity(doc: Document | null | undefined): {
clip_count: number;
media_clip_count: number;
} {
if (!doc) return { clip_count: 0, media_clip_count: 0 };
const elements = parseTimelineFromDOM(doc, readTimelineDurationFromDocument(doc));
return {
clip_count: elements.length,
media_clip_count: elements.filter(({ tag }) => tag === "video" || tag === "audio").length,
};
}
export function usePreviewFirstFrameTelemetry(previewSlots: PreviewIframeSlot[]) {
const previewSlotsRef = useRef(previewSlots);
previewSlotsRef.current = previewSlots;
const reportedPaintsRef = useRef(new Set<string>());
return useCallback(
(
slot: PreviewIframeSlot,
{
iframe,
startedAt,
loadId,
}: { iframe: HTMLIFrameElement; startedAt: number; loadId: number },
) => {
const currentSlot = previewSlotsRef.current.find(
(candidate) => candidate.gen === slot.gen && candidate.role === slot.role,
);
if (!currentSlot) return;
const reportKey = `${slot.role}:${slot.gen}:${loadId}`;
if (reportedPaintsRef.current.has(reportKey)) return;
reportedPaintsRef.current.add(reportKey);
const complexity = readPreviewComplexity(iframe.contentDocument);
trackPreviewFirstFrame({
duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
composition_seconds: readTimelineDurationFromDocument(iframe.contentDocument),
...complexity,
studio_version: getBrowserSystemMeta().studio_version,
});
},
[],
);
}
@@ -9,6 +9,7 @@ vi.mock("./client", () => ({
const {
trackStudioSessionStart,
trackPreviewFirstFrame,
trackStudioRenderStart,
trackStudioRazorSplit,
trackStudioExpandedClipEdit,
@@ -34,6 +35,23 @@ describe("studio telemetry events", () => {
expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: false });
});
it("trackPreviewFirstFrame emits timing, composition duration, and version", () => {
trackPreviewFirstFrame({
duration_ms: 842,
composition_seconds: 12.5,
clip_count: 8,
media_clip_count: 5,
studio_version: "0.8.56",
});
expect(trackEvent).toHaveBeenCalledWith("preview_first_frame", {
duration_ms: 842,
composition_seconds: 12.5,
clip_count: 8,
media_clip_count: 5,
studio_version: "0.8.56",
});
});
it("trackStudioRenderStart emits 'studio_render_start' with all render opts", () => {
trackStudioRenderStart({
fps: 30,
+10
View File
@@ -11,6 +11,16 @@ export function trackStudioSessionStart(props: { has_project: boolean }): void {
});
}
export function trackPreviewFirstFrame(props: {
duration_ms: number;
composition_seconds: number;
clip_count: number;
media_clip_count: number;
studio_version: string;
}): void {
trackEvent("preview_first_frame", props);
}
export function trackStudioRenderStart(props: {
fps: number;
quality: string;