Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<p>FPS: {metrics.avgFps.toFixed(1)}</p>
<p>Frame time: {metrics.lensFrameProcessingTimeMsAvg.toFixed(1)}ms</p>
</div>
);
}
```

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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
271 changes: 271 additions & 0 deletions src/useLensFrameMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useInternalCameraKit>;

function createMockMetrics(overrides: Partial<ComputedFrameMetrics> = {}): 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<typeof createMockMeasurement>) {
return {
metrics: {
beginMeasurement: jest.fn().mockReturnValue(measurement),
},
};
}

function setupContext(overrides: Partial<ReturnType<typeof useInternalCameraKit>> = {}) {
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();
});
});
79 changes: 79 additions & 0 deletions src/useLensFrameMetrics.ts
Original file line number Diff line number Diff line change
@@ -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 <div>FPS: {metrics.avgFps.toFixed(1)}</div>;
* }
* ```
*/
export function useLensFrameMetrics(options: UseLensFrameMetricsOptions): ComputedFrameMetrics | undefined {
const { enabled = true, interval } = options;
const { currentSession, lens } = useInternalCameraKit();
const [metrics, setMetrics] = useState<ComputedFrameMetrics | undefined>(undefined);
const measurementRef = useRef<LensPerformanceMeasurement | undefined>(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;
}
Loading