diff --git a/README.md b/README.md index b5fa8cb..ccc8c55 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,32 @@ function Preview() { } ``` +### Frame Metrics + +Use `useLensFrameMetrics` to monitor lens rendering performance: + +```tsx +import { useLensFrameMetrics } from "@snap/react-camera-kit"; + +function PerformanceOverlay() { + const metrics = useLensFrameMetrics({ interval: 500 }); + + if (!metrics) return null; + + return ( +
+

FPS: {metrics.avgFps.toFixed(1)}

+

Frame time: {metrics.lensFrameProcessingTimeMsAvg.toFixed(1)}ms

+
+ ); +} +``` + +The hook accepts: + +- `interval` (required) — polling interval in milliseconds +- `enabled` (optional, defaults to `true`) — set to `false` to disable measurement without unmounting + ## Full example: Lens switcher ```tsx diff --git a/src/index.ts b/src/index.ts index 809a0cb..67284ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export { useApplyLens } from "./useApplyLens"; export { useApplySource } from "./useApplySource"; export { usePlaybackOptions } from "./usePlaybackOptions"; export type { PlaybackOptions } from "./usePlaybackOptions"; +export { useLensFrameMetrics } from "./useLensFrameMetrics"; // Types export type { diff --git a/src/useLensFrameMetrics.test.ts b/src/useLensFrameMetrics.test.ts new file mode 100644 index 0000000..24ce2c8 --- /dev/null +++ b/src/useLensFrameMetrics.test.ts @@ -0,0 +1,271 @@ +jest.mock("@snap/camera-kit", () => ({})); + +import { renderHook, act } from "@testing-library/react"; +import { useLensFrameMetrics } from "./useLensFrameMetrics"; +import { useInternalCameraKit } from "./CameraKitProvider"; +import { ComputedFrameMetrics } from "@snap/camera-kit"; + +jest.mock("./CameraKitProvider"); + +const mockUseInternalCameraKit = useInternalCameraKit as jest.MockedFunction; + +function createMockMetrics(overrides: Partial = {}): ComputedFrameMetrics { + return { + avgFps: 30, + lensFrameProcessingTimeMsAvg: 16.5, + lensFrameProcessingTimeMsStd: 2.1, + lensFrameProcessingTimeMsMedian: 16.0, + lensFrameProcessingN: 100, + ...overrides, + }; +} + +function createMockMeasurement() { + return { + measure: jest.fn().mockReturnValue(createMockMetrics()), + reset: jest.fn(), + end: jest.fn(), + }; +} + +function createMockSession(measurement: ReturnType) { + return { + metrics: { + beginMeasurement: jest.fn().mockReturnValue(measurement), + }, + }; +} + +function setupContext(overrides: Partial> = {}) { + const measurement = createMockMeasurement(); + const session = createMockSession(measurement); + const mockGetLogger = jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }); + + mockUseInternalCameraKit.mockReturnValue({ + currentSession: session as any, + sdkStatus: "ready", + lens: { + lensId: "lens-1", + lensGroupId: "group-1", + status: "ready", + error: undefined, + lens: undefined, + lensLaunchData: undefined, + lensReadyGuard: undefined, + }, + getLogger: mockGetLogger, + ...overrides, + } as any); + + return { measurement, session, mockGetLogger }; +} + +describe("useLensFrameMetrics", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("returns undefined when no session is available", () => { + setupContext({ currentSession: undefined, sdkStatus: "initializing" }); + + const { result } = renderHook(() => useLensFrameMetrics({ interval: 500 })); + + expect(result.current).toBeUndefined(); + }); + + it("calls beginMeasurement when session becomes available", () => { + const { session } = setupContext(); + + renderHook(() => useLensFrameMetrics({ interval: 500 })); + + expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1); + }); + + it("polls measure() at the specified interval and returns metrics", () => { + const { measurement } = setupContext(); + const metrics = createMockMetrics({ avgFps: 60 }); + measurement.measure.mockReturnValue(metrics); + + const { result } = renderHook(() => useLensFrameMetrics({ interval: 500 })); + + // No metrics yet before first interval tick + expect(result.current).toBeUndefined(); + + // Advance past first interval + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(measurement.measure).toHaveBeenCalled(); + expect(result.current).toEqual(metrics); + }); + + it("polls repeatedly at the interval", () => { + const { measurement } = setupContext(); + + renderHook(() => useLensFrameMetrics({ interval: 200 })); + + act(() => { + jest.advanceTimersByTime(600); + }); + + expect(measurement.measure).toHaveBeenCalledTimes(3); + }); + + it("calls reset() when lens changes", () => { + const { measurement } = setupContext(); + + const { rerender } = renderHook(() => useLensFrameMetrics({ interval: 500 })); + + // Change the lens + mockUseInternalCameraKit.mockReturnValue({ + ...mockUseInternalCameraKit.mock.results[0]!.value, + lens: { + lensId: "lens-2", + lensGroupId: "group-1", + status: "ready", + error: undefined, + lens: undefined, + lensLaunchData: undefined, + lensReadyGuard: undefined, + }, + } as any); + + rerender(); + + expect(measurement.reset).toHaveBeenCalledTimes(1); + }); + + it("calls end() on unmount", () => { + const { measurement } = setupContext(); + + const { unmount } = renderHook(() => useLensFrameMetrics({ interval: 500 })); + + unmount(); + + expect(measurement.end).toHaveBeenCalledTimes(1); + }); + + it("calls end() then beginMeasurement() when session changes", () => { + const { measurement: measurement1 } = setupContext(); + + const { rerender } = renderHook(() => useLensFrameMetrics({ interval: 500 })); + + expect(measurement1.end).not.toHaveBeenCalled(); + + // New session (re-bootstrap) + const measurement2 = createMockMeasurement(); + const session2 = createMockSession(measurement2); + + mockUseInternalCameraKit.mockReturnValue({ + ...mockUseInternalCameraKit.mock.results[0]!.value, + currentSession: session2 as any, + } as any); + + rerender(); + + expect(measurement1.end).toHaveBeenCalledTimes(1); + expect(session2.metrics.beginMeasurement).toHaveBeenCalledTimes(1); + }); + + it("does not start measurement when enabled is false", () => { + const { session } = setupContext(); + + const { result } = renderHook(() => useLensFrameMetrics({ interval: 500, enabled: false })); + + expect(session.metrics.beginMeasurement).not.toHaveBeenCalled(); + expect(result.current).toBeUndefined(); + }); + + it("stops measurement when enabled changes to false", () => { + const { measurement } = setupContext(); + measurement.measure.mockReturnValue(createMockMetrics({ avgFps: 60 })); + + const { result, rerender } = renderHook(({ enabled }) => useLensFrameMetrics({ interval: 500, enabled }), { + initialProps: { enabled: true }, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBeDefined(); + + rerender({ enabled: false }); + + expect(measurement.end).toHaveBeenCalledTimes(1); + expect(result.current).toBeUndefined(); + }); + + it("starts measurement when enabled changes to true", () => { + const { session } = setupContext(); + + const { rerender } = renderHook(({ enabled }) => useLensFrameMetrics({ interval: 500, enabled }), { + initialProps: { enabled: false }, + }); + + expect(session.metrics.beginMeasurement).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1); + }); + + it("does not restart measurement when interval changes", () => { + const { measurement, session } = setupContext(); + + const { rerender } = renderHook(({ interval }) => useLensFrameMetrics({ interval }), { + initialProps: { interval: 500 }, + }); + + expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1); + measurement.end.mockClear(); + + rerender({ interval: 1000 }); + + // Should NOT end/restart the measurement + expect(measurement.end).not.toHaveBeenCalled(); + expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1); + + // But should poll at the new interval + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(measurement.measure).toHaveBeenCalled(); + }); + + it("clears metrics state when session is lost", () => { + const { measurement } = setupContext(); + measurement.measure.mockReturnValue(createMockMetrics({ avgFps: 60 })); + + const { result, rerender } = renderHook(() => useLensFrameMetrics({ interval: 500 })); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBeDefined(); + + // Session goes away (e.g. error state) + mockUseInternalCameraKit.mockReturnValue({ + ...mockUseInternalCameraKit.mock.results[0]!.value, + currentSession: undefined, + sdkStatus: "error", + } as any); + + rerender(); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/src/useLensFrameMetrics.ts b/src/useLensFrameMetrics.ts new file mode 100644 index 0000000..aeb6772 --- /dev/null +++ b/src/useLensFrameMetrics.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; +import { ComputedFrameMetrics, LensPerformanceMeasurement } from "@snap/camera-kit"; +import { useInternalCameraKit } from "./CameraKitProvider"; + +interface UseLensFrameMetricsOptions { + /** Polling interval in milliseconds. How often measurement.measure() is called to update state. */ + interval: number; + /** Whether measurement is active. Defaults to true. When false, no measurement is started and the hook returns undefined. */ + enabled?: boolean; +} + +/** + * Declaratively measures lens rendering performance. + * + * This hook manages the lifecycle of a {@link LensPerformanceMeasurement} from the CameraKit SDK. + * It begins measurement when a session is available, polls at the specified interval, + * resets when the active lens changes, and cleans up on unmount. + * + * @param options - Configuration options + * @returns The latest computed frame metrics, or undefined if no session is available. + * + * @example + * ```tsx + * function PerformanceOverlay() { + * const metrics = useLensFrameMetrics({ interval: 500 }); + * + * if (!metrics) return null; + * + * return
FPS: {metrics.avgFps.toFixed(1)}
; + * } + * ``` + */ +export function useLensFrameMetrics(options: UseLensFrameMetricsOptions): ComputedFrameMetrics | undefined { + const { enabled = true, interval } = options; + const { currentSession, lens } = useInternalCameraKit(); + const [metrics, setMetrics] = useState(undefined); + const measurementRef = useRef(undefined); + const isFirstLensRender = useRef(true); + + // Begin/end measurement lifecycle + useEffect(() => { + if (!currentSession || !enabled) { + setMetrics(undefined); + return; + } + + const measurement = currentSession.metrics.beginMeasurement(); + measurementRef.current = measurement; + + return () => { + measurement.end(); + measurementRef.current = undefined; + isFirstLensRender.current = true; + }; + }, [currentSession, enabled]); + + // Poll measurement on interval (separate so changing interval doesn't restart measurement) + useEffect(() => { + if (!currentSession || !enabled) return; + + const intervalId = setInterval(() => { + const result = measurementRef.current?.measure(); + if (result) setMetrics(result); + }, interval); + + return () => clearInterval(intervalId); + }, [currentSession, enabled, interval]); + + // Reset measurement when lens changes (skip on initial mount) + useEffect(() => { + if (isFirstLensRender.current) { + isFirstLensRender.current = false; + return; + } + measurementRef.current?.reset(); + }, [lens.lensId]); + + return metrics; +}