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;
+}