mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(studio): track preview time to first frame (#4226)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user