Skip to content

Commit a0ae0e6

Browse files
feat: add useLensFrameMetrics hook (#3)
## Summary - Add `useLensFrameMetrics` hook that exposes lens rendering performance data (`ComputedFrameMetrics`) from the CameraKit SDK's `session.metrics` API - The hook manages the full `LensPerformanceMeasurement` lifecycle: begins measurement when a session is available, polls at a configurable interval, resets on lens change, and calls `end()` on cleanup to prevent memory leaks - Supports an `enabled` option (defaults to `true`) so consumers can toggle measurement without breaking rules of hooks ## Usage ```tsx import { useLensFrameMetrics } from "@snap/react-camera-kit"; function PerformanceOverlay() { const metrics = useLensFrameMetrics({ interval: 500 }); if (!metrics) return null; return ( <div> <p>FPS: {metrics.avgFps.toFixed(1)}</p> <p>Frame time: {metrics.lensFrameProcessingTimeMsAvg.toFixed(1)}ms</p> </div> ); } ``` ## Test plan - [ ] 11 new unit tests covering: no session, begin/end lifecycle, polling, lens change reset, unmount cleanup, session re-bootstrap, enabled/disabled toggling, session loss - [ ] Full test suite passes (301 tests) - [ ] Build passes (ESM + CJS) - [ ] Verify in demo app: hook returns metrics when lens is active, returns `undefined` when no session
1 parent 6e93aa6 commit a0ae0e6

4 files changed

Lines changed: 377 additions & 0 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,32 @@ function Preview() {
271271
}
272272
```
273273

274+
### Frame Metrics
275+
276+
Use `useLensFrameMetrics` to monitor lens rendering performance:
277+
278+
```tsx
279+
import { useLensFrameMetrics } from "@snap/react-camera-kit";
280+
281+
function PerformanceOverlay() {
282+
const metrics = useLensFrameMetrics({ interval: 500 });
283+
284+
if (!metrics) return null;
285+
286+
return (
287+
<div>
288+
<p>FPS: {metrics.avgFps.toFixed(1)}</p>
289+
<p>Frame time: {metrics.lensFrameProcessingTimeMsAvg.toFixed(1)}ms</p>
290+
</div>
291+
);
292+
}
293+
```
294+
295+
The hook accepts:
296+
297+
- `interval` (required) — polling interval in milliseconds
298+
- `enabled` (optional, defaults to `true`) — set to `false` to disable measurement without unmounting
299+
274300
## Full example: Lens switcher
275301

276302
```tsx

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export { useApplyLens } from "./useApplyLens";
1717
export { useApplySource } from "./useApplySource";
1818
export { usePlaybackOptions } from "./usePlaybackOptions";
1919
export type { PlaybackOptions } from "./usePlaybackOptions";
20+
export { useLensFrameMetrics } from "./useLensFrameMetrics";
2021

2122
// Types
2223
export type {

src/useLensFrameMetrics.test.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
jest.mock("@snap/camera-kit", () => ({}));
2+
3+
import { renderHook, act } from "@testing-library/react";
4+
import { useLensFrameMetrics } from "./useLensFrameMetrics";
5+
import { useInternalCameraKit } from "./CameraKitProvider";
6+
import { ComputedFrameMetrics } from "@snap/camera-kit";
7+
8+
jest.mock("./CameraKitProvider");
9+
10+
const mockUseInternalCameraKit = useInternalCameraKit as jest.MockedFunction<typeof useInternalCameraKit>;
11+
12+
function createMockMetrics(overrides: Partial<ComputedFrameMetrics> = {}): ComputedFrameMetrics {
13+
return {
14+
avgFps: 30,
15+
lensFrameProcessingTimeMsAvg: 16.5,
16+
lensFrameProcessingTimeMsStd: 2.1,
17+
lensFrameProcessingTimeMsMedian: 16.0,
18+
lensFrameProcessingN: 100,
19+
...overrides,
20+
};
21+
}
22+
23+
function createMockMeasurement() {
24+
return {
25+
measure: jest.fn().mockReturnValue(createMockMetrics()),
26+
reset: jest.fn(),
27+
end: jest.fn(),
28+
};
29+
}
30+
31+
function createMockSession(measurement: ReturnType<typeof createMockMeasurement>) {
32+
return {
33+
metrics: {
34+
beginMeasurement: jest.fn().mockReturnValue(measurement),
35+
},
36+
};
37+
}
38+
39+
function setupContext(overrides: Partial<ReturnType<typeof useInternalCameraKit>> = {}) {
40+
const measurement = createMockMeasurement();
41+
const session = createMockSession(measurement);
42+
const mockGetLogger = jest.fn().mockReturnValue({
43+
info: jest.fn(),
44+
warn: jest.fn(),
45+
error: jest.fn(),
46+
debug: jest.fn(),
47+
});
48+
49+
mockUseInternalCameraKit.mockReturnValue({
50+
currentSession: session as any,
51+
sdkStatus: "ready",
52+
lens: {
53+
lensId: "lens-1",
54+
lensGroupId: "group-1",
55+
status: "ready",
56+
error: undefined,
57+
lens: undefined,
58+
lensLaunchData: undefined,
59+
lensReadyGuard: undefined,
60+
},
61+
getLogger: mockGetLogger,
62+
...overrides,
63+
} as any);
64+
65+
return { measurement, session, mockGetLogger };
66+
}
67+
68+
describe("useLensFrameMetrics", () => {
69+
beforeEach(() => {
70+
jest.clearAllMocks();
71+
jest.useFakeTimers();
72+
});
73+
74+
afterEach(() => {
75+
jest.useRealTimers();
76+
});
77+
78+
it("returns undefined when no session is available", () => {
79+
setupContext({ currentSession: undefined, sdkStatus: "initializing" });
80+
81+
const { result } = renderHook(() => useLensFrameMetrics({ interval: 500 }));
82+
83+
expect(result.current).toBeUndefined();
84+
});
85+
86+
it("calls beginMeasurement when session becomes available", () => {
87+
const { session } = setupContext();
88+
89+
renderHook(() => useLensFrameMetrics({ interval: 500 }));
90+
91+
expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it("polls measure() at the specified interval and returns metrics", () => {
95+
const { measurement } = setupContext();
96+
const metrics = createMockMetrics({ avgFps: 60 });
97+
measurement.measure.mockReturnValue(metrics);
98+
99+
const { result } = renderHook(() => useLensFrameMetrics({ interval: 500 }));
100+
101+
// No metrics yet before first interval tick
102+
expect(result.current).toBeUndefined();
103+
104+
// Advance past first interval
105+
act(() => {
106+
jest.advanceTimersByTime(500);
107+
});
108+
109+
expect(measurement.measure).toHaveBeenCalled();
110+
expect(result.current).toEqual(metrics);
111+
});
112+
113+
it("polls repeatedly at the interval", () => {
114+
const { measurement } = setupContext();
115+
116+
renderHook(() => useLensFrameMetrics({ interval: 200 }));
117+
118+
act(() => {
119+
jest.advanceTimersByTime(600);
120+
});
121+
122+
expect(measurement.measure).toHaveBeenCalledTimes(3);
123+
});
124+
125+
it("calls reset() when lens changes", () => {
126+
const { measurement } = setupContext();
127+
128+
const { rerender } = renderHook(() => useLensFrameMetrics({ interval: 500 }));
129+
130+
// Change the lens
131+
mockUseInternalCameraKit.mockReturnValue({
132+
...mockUseInternalCameraKit.mock.results[0]!.value,
133+
lens: {
134+
lensId: "lens-2",
135+
lensGroupId: "group-1",
136+
status: "ready",
137+
error: undefined,
138+
lens: undefined,
139+
lensLaunchData: undefined,
140+
lensReadyGuard: undefined,
141+
},
142+
} as any);
143+
144+
rerender();
145+
146+
expect(measurement.reset).toHaveBeenCalledTimes(1);
147+
});
148+
149+
it("calls end() on unmount", () => {
150+
const { measurement } = setupContext();
151+
152+
const { unmount } = renderHook(() => useLensFrameMetrics({ interval: 500 }));
153+
154+
unmount();
155+
156+
expect(measurement.end).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it("calls end() then beginMeasurement() when session changes", () => {
160+
const { measurement: measurement1 } = setupContext();
161+
162+
const { rerender } = renderHook(() => useLensFrameMetrics({ interval: 500 }));
163+
164+
expect(measurement1.end).not.toHaveBeenCalled();
165+
166+
// New session (re-bootstrap)
167+
const measurement2 = createMockMeasurement();
168+
const session2 = createMockSession(measurement2);
169+
170+
mockUseInternalCameraKit.mockReturnValue({
171+
...mockUseInternalCameraKit.mock.results[0]!.value,
172+
currentSession: session2 as any,
173+
} as any);
174+
175+
rerender();
176+
177+
expect(measurement1.end).toHaveBeenCalledTimes(1);
178+
expect(session2.metrics.beginMeasurement).toHaveBeenCalledTimes(1);
179+
});
180+
181+
it("does not start measurement when enabled is false", () => {
182+
const { session } = setupContext();
183+
184+
const { result } = renderHook(() => useLensFrameMetrics({ interval: 500, enabled: false }));
185+
186+
expect(session.metrics.beginMeasurement).not.toHaveBeenCalled();
187+
expect(result.current).toBeUndefined();
188+
});
189+
190+
it("stops measurement when enabled changes to false", () => {
191+
const { measurement } = setupContext();
192+
measurement.measure.mockReturnValue(createMockMetrics({ avgFps: 60 }));
193+
194+
const { result, rerender } = renderHook(({ enabled }) => useLensFrameMetrics({ interval: 500, enabled }), {
195+
initialProps: { enabled: true },
196+
});
197+
198+
act(() => {
199+
jest.advanceTimersByTime(500);
200+
});
201+
202+
expect(result.current).toBeDefined();
203+
204+
rerender({ enabled: false });
205+
206+
expect(measurement.end).toHaveBeenCalledTimes(1);
207+
expect(result.current).toBeUndefined();
208+
});
209+
210+
it("starts measurement when enabled changes to true", () => {
211+
const { session } = setupContext();
212+
213+
const { rerender } = renderHook(({ enabled }) => useLensFrameMetrics({ interval: 500, enabled }), {
214+
initialProps: { enabled: false },
215+
});
216+
217+
expect(session.metrics.beginMeasurement).not.toHaveBeenCalled();
218+
219+
rerender({ enabled: true });
220+
221+
expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1);
222+
});
223+
224+
it("does not restart measurement when interval changes", () => {
225+
const { measurement, session } = setupContext();
226+
227+
const { rerender } = renderHook(({ interval }) => useLensFrameMetrics({ interval }), {
228+
initialProps: { interval: 500 },
229+
});
230+
231+
expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1);
232+
measurement.end.mockClear();
233+
234+
rerender({ interval: 1000 });
235+
236+
// Should NOT end/restart the measurement
237+
expect(measurement.end).not.toHaveBeenCalled();
238+
expect(session.metrics.beginMeasurement).toHaveBeenCalledTimes(1);
239+
240+
// But should poll at the new interval
241+
act(() => {
242+
jest.advanceTimersByTime(1000);
243+
});
244+
245+
expect(measurement.measure).toHaveBeenCalled();
246+
});
247+
248+
it("clears metrics state when session is lost", () => {
249+
const { measurement } = setupContext();
250+
measurement.measure.mockReturnValue(createMockMetrics({ avgFps: 60 }));
251+
252+
const { result, rerender } = renderHook(() => useLensFrameMetrics({ interval: 500 }));
253+
254+
act(() => {
255+
jest.advanceTimersByTime(500);
256+
});
257+
258+
expect(result.current).toBeDefined();
259+
260+
// Session goes away (e.g. error state)
261+
mockUseInternalCameraKit.mockReturnValue({
262+
...mockUseInternalCameraKit.mock.results[0]!.value,
263+
currentSession: undefined,
264+
sdkStatus: "error",
265+
} as any);
266+
267+
rerender();
268+
269+
expect(result.current).toBeUndefined();
270+
});
271+
});

src/useLensFrameMetrics.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import { ComputedFrameMetrics, LensPerformanceMeasurement } from "@snap/camera-kit";
3+
import { useInternalCameraKit } from "./CameraKitProvider";
4+
5+
interface UseLensFrameMetricsOptions {
6+
/** Polling interval in milliseconds. How often measurement.measure() is called to update state. */
7+
interval: number;
8+
/** Whether measurement is active. Defaults to true. When false, no measurement is started and the hook returns undefined. */
9+
enabled?: boolean;
10+
}
11+
12+
/**
13+
* Declaratively measures lens rendering performance.
14+
*
15+
* This hook manages the lifecycle of a {@link LensPerformanceMeasurement} from the CameraKit SDK.
16+
* It begins measurement when a session is available, polls at the specified interval,
17+
* resets when the active lens changes, and cleans up on unmount.
18+
*
19+
* @param options - Configuration options
20+
* @returns The latest computed frame metrics, or undefined if no session is available.
21+
*
22+
* @example
23+
* ```tsx
24+
* function PerformanceOverlay() {
25+
* const metrics = useLensFrameMetrics({ interval: 500 });
26+
*
27+
* if (!metrics) return null;
28+
*
29+
* return <div>FPS: {metrics.avgFps.toFixed(1)}</div>;
30+
* }
31+
* ```
32+
*/
33+
export function useLensFrameMetrics(options: UseLensFrameMetricsOptions): ComputedFrameMetrics | undefined {
34+
const { enabled = true, interval } = options;
35+
const { currentSession, lens } = useInternalCameraKit();
36+
const [metrics, setMetrics] = useState<ComputedFrameMetrics | undefined>(undefined);
37+
const measurementRef = useRef<LensPerformanceMeasurement | undefined>(undefined);
38+
const isFirstLensRender = useRef(true);
39+
40+
// Begin/end measurement lifecycle
41+
useEffect(() => {
42+
if (!currentSession || !enabled) {
43+
setMetrics(undefined);
44+
return;
45+
}
46+
47+
const measurement = currentSession.metrics.beginMeasurement();
48+
measurementRef.current = measurement;
49+
50+
return () => {
51+
measurement.end();
52+
measurementRef.current = undefined;
53+
isFirstLensRender.current = true;
54+
};
55+
}, [currentSession, enabled]);
56+
57+
// Poll measurement on interval (separate so changing interval doesn't restart measurement)
58+
useEffect(() => {
59+
if (!currentSession || !enabled) return;
60+
61+
const intervalId = setInterval(() => {
62+
const result = measurementRef.current?.measure();
63+
if (result) setMetrics(result);
64+
}, interval);
65+
66+
return () => clearInterval(intervalId);
67+
}, [currentSession, enabled, interval]);
68+
69+
// Reset measurement when lens changes (skip on initial mount)
70+
useEffect(() => {
71+
if (isFirstLensRender.current) {
72+
isFirstLensRender.current = false;
73+
return;
74+
}
75+
measurementRef.current?.reset();
76+
}, [lens.lensId]);
77+
78+
return metrics;
79+
}

0 commit comments

Comments
 (0)