From d043cf4275b8298c9d94c6954a84a7cf18760a6d Mon Sep 17 00:00:00 2001 From: Mikalai Silivonik Date: Thu, 2 Apr 2026 20:19:25 -0400 Subject: [PATCH 1/2] Add design spec for useLensPerformanceMetrics hook Co-Authored-By: Claude Opus 4.6 (1M context) Add implementation plan for useLensPerformanceMetrics hook Co-Authored-By: Claude Opus 4.6 (1M context) test: add failing tests for useLensPerformanceMetrics hook Co-Authored-By: Claude Sonnet 4.6 fix: resolve TypeScript errors in useLensPerformanceMetrics tests Co-Authored-By: Claude Sonnet 4.6 fix: align test mock shapes with actual SDK types Co-Authored-By: Claude Sonnet 4.6 feat: implement useLensPerformanceMetrics hook Co-Authored-By: Claude Sonnet 4.6 fix: reset isFirstLensRender on session cleanup Prevents spurious measurement.reset() call on the first lens applied to a new session after re-bootstrap. Co-Authored-By: Claude Opus 4.6 (1M context) feat: export useLensPerformanceMetrics and ComputedFrameMetrics type Co-Authored-By: Claude Opus 4.6 (1M context) feat: add enabled option to useLensPerformanceMetrics Allows consumers to conditionally enable/disable measurement without breaking rules of hooks. Defaults to true. Co-Authored-By: Claude Opus 4.6 (1M context) fix: remove ComputedFrameMetrics re-export from index Consumers should import SDK types directly from @snap/camera-kit, consistent with how other SDK types (Lens, LensLaunchData, etc.) are not re-exported. Co-Authored-By: Claude Opus 4.6 (1M context) docs: add useLensPerformanceMetrics to README Co-Authored-By: Claude Opus 4.6 (1M context) refactor: rename useLensPerformanceMetrics to useLensFrameMetrics Aligns with the SDK's ComputedFrameMetrics return type and is more specific about what is being measured. Co-Authored-By: Claude Opus 4.6 (1M context) chore: remove design and plan docs Co-Authored-By: Claude Opus 4.6 (1M context) refactor: remove lens cache fix from this branch The lens cache clearing on re-bootstrap will go in a separate PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 26 ++++ src/index.ts | 1 + src/useLensFrameMetrics.test.ts | 247 ++++++++++++++++++++++++++++++++ src/useLensFrameMetrics.ts | 72 ++++++++++ 4 files changed, 346 insertions(+) create mode 100644 src/useLensFrameMetrics.test.ts create mode 100644 src/useLensFrameMetrics.ts 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..3357eef --- /dev/null +++ b/src/useLensFrameMetrics.test.ts @@ -0,0 +1,247 @@ +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("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..2909d2f --- /dev/null +++ b/src/useLensFrameMetrics.ts @@ -0,0 +1,72 @@ +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 } = options; + const { currentSession, lens } = useInternalCameraKit(); + const [metrics, setMetrics] = useState(undefined); + const measurementRef = useRef(null); + const isFirstLensRender = useRef(true); + + // Begin/end measurement and set up polling interval + useEffect(() => { + if (!currentSession || !enabled) { + setMetrics(undefined); + return; + } + + const measurement = currentSession.metrics.beginMeasurement(); + measurementRef.current = measurement; + + const intervalId = setInterval(() => { + setMetrics(measurement.measure()); + }, options.interval); + + return () => { + clearInterval(intervalId); + measurement.end(); + measurementRef.current = null; + isFirstLensRender.current = true; + }; + }, [currentSession, options.interval, enabled]); + + // Reset measurement when lens changes (skip on initial mount) + useEffect(() => { + if (isFirstLensRender.current) { + isFirstLensRender.current = false; + return; + } + measurementRef.current?.reset(); + }, [lens.lensId]); + + return metrics; +} From aca3a4362dc202f93263569e182801416bf7a7c7 Mon Sep 17 00:00:00 2001 From: Mikalai Silivonik Date: Fri, 3 Apr 2026 10:45:49 -0400 Subject: [PATCH 2/2] fix: separate measurement lifecycle from polling interval Changing the polling interval no longer restarts the measurement and discards accumulated stats. The measurement lifecycle is now keyed only on session/enabled, while the polling interval is managed in a separate effect. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useLensFrameMetrics.test.ts | 24 ++++++++++++++++++++++++ src/useLensFrameMetrics.ts | 27 +++++++++++++++++---------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/useLensFrameMetrics.test.ts b/src/useLensFrameMetrics.test.ts index 3357eef..24ce2c8 100644 --- a/src/useLensFrameMetrics.test.ts +++ b/src/useLensFrameMetrics.test.ts @@ -221,6 +221,30 @@ describe("useLensFrameMetrics", () => { 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 })); diff --git a/src/useLensFrameMetrics.ts b/src/useLensFrameMetrics.ts index 2909d2f..aeb6772 100644 --- a/src/useLensFrameMetrics.ts +++ b/src/useLensFrameMetrics.ts @@ -31,13 +31,13 @@ interface UseLensFrameMetricsOptions { * ``` */ export function useLensFrameMetrics(options: UseLensFrameMetricsOptions): ComputedFrameMetrics | undefined { - const { enabled = true } = options; + const { enabled = true, interval } = options; const { currentSession, lens } = useInternalCameraKit(); const [metrics, setMetrics] = useState(undefined); - const measurementRef = useRef(null); + const measurementRef = useRef(undefined); const isFirstLensRender = useRef(true); - // Begin/end measurement and set up polling interval + // Begin/end measurement lifecycle useEffect(() => { if (!currentSession || !enabled) { setMetrics(undefined); @@ -47,17 +47,24 @@ export function useLensFrameMetrics(options: UseLensFrameMetricsOptions): Comput const measurement = currentSession.metrics.beginMeasurement(); measurementRef.current = measurement; - const intervalId = setInterval(() => { - setMetrics(measurement.measure()); - }, options.interval); - return () => { - clearInterval(intervalId); measurement.end(); - measurementRef.current = null; + measurementRef.current = undefined; isFirstLensRender.current = true; }; - }, [currentSession, options.interval, enabled]); + }, [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(() => {