diff --git a/apps/simple-camera/__tests__/README.md b/apps/simple-camera/__tests__/README.md index ddcc20cb5e..23e37ad7a2 100644 --- a/apps/simple-camera/__tests__/README.md +++ b/apps/simple-camera/__tests__/README.md @@ -27,7 +27,7 @@ Tests are split by domain. Each file tests one slice of the imperative `VisionCa | [visioncamera.multi-output.harness.ts](visioncamera.multi-output.harness.ts) | Multi-output sessions that combine photo, video, and frame outputs, output replacement while other outputs stay attached, persistent recording across session restarts | | [visioncamera.constraints.harness.ts](visioncamera.constraints.harness.ts) | `VisionCamera.resolveConstraints` + `onSessionConfigSelected`, FPS / HDR / stabilization / binned / pixelFormat / resolutionBias constraints | | [visioncamera.controller.harness.ts](visioncamera.controller.harness.ts) | `CameraController` — zoom, torch, exposure bias, focus metering, low-light boost, subject area listener | -| [visioncamera.hooks.harness.tsx](visioncamera.hooks.harness.tsx) | React hook reactivity for `useCameraDevice(...)` position and physical-device filter changes, and `useCamera(...).onUIRotationChanged` | +| [visioncamera.hooks.harness.tsx](visioncamera.hooks.harness.tsx) | React hook reactivity for `useCameraDevice(...)` position and physical-device filter changes, `useCamera(...).onUIRotationChanged`, and `useFrameOutput(...)` unmount cleanup | | [visioncamera.utils.harness.ts](visioncamera.utils.harness.ts) | Pure public utilities such as `getUIRotation(...)` across every output/interface orientation pair | | [visioncamera.coordinates.harness.ts](visioncamera.coordinates.harness.ts) | `Frame.convertFramePointToCameraPoint` / `convertCameraPointToFramePoint`, `PreviewView.convertViewPointToCameraPoint` / `convertCameraPointToViewPoint`, `PreviewView.createMeteringPoint`, `convertScannedObjectCoordinatesToViewCoordinates`, end-to-end Frame → Camera → View round-trip | | [visioncamera.nativepreviewview.harness.tsx](visioncamera.nativepreviewview.harness.tsx) | Bare `NativePreviewView` lifecycle, layout-sensitive preview regression coverage, `resizeMode`, Android `implementationMode`, gesture controllers, multi-preview mounting, `PreviewView` ref methods, Android `takeSnapshot()` dimensions | diff --git a/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx b/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx index 8cb22d5522..cc18768d8c 100644 --- a/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx +++ b/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { StyleSheet } from 'react-native' import { + assert, beforeAll, describe, expect, @@ -9,6 +10,7 @@ import { type Mock, render, waitFor, + waitUntil, } from 'react-native-harness' import { Screen, @@ -18,6 +20,7 @@ import { import type { CameraDevice, CameraDeviceFactory, + CameraFrameOutput, CameraOrientation, CameraPhotoOutput, CameraPosition, @@ -30,12 +33,14 @@ import { getUIRotation, useCamera, useCameraDevice, + useFrameOutput, useOrientation, usePhotoOutput, usePreviewOutput, useVideoOutput, VisionCamera, } from 'react-native-vision-camera' +import { scheduleOnRN } from 'react-native-worklets' interface DeviceSnapshot { requestedPosition: TargetCameraPosition @@ -112,6 +117,32 @@ function CameraDeviceProbe({ return null } +interface FrameOutputProbeProps { + onFrameOutput: (frameOutput: CameraFrameOutput) => void + onFrameReceived: () => void +} + +function FrameOutputProbe({ + onFrameOutput, + onFrameReceived, +}: FrameOutputProbeProps): null { + const frameOutput = useFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + onFrame(frame) { + 'worklet' + scheduleOnRN(onFrameReceived) + frame.dispose() + }, + }) + + useEffect(() => { + onFrameOutput(frameOutput) + }, [frameOutput, onFrameOutput]) + + return null +} + async function expectLatestDeviceSnapshot( onSnapshot: Mock<(snapshot: DeviceSnapshot) => void>, position: TargetCameraPosition, @@ -131,11 +162,15 @@ async function expectLatestDeviceSnapshot( describe('VisionCamera - Hooks', () => { let factory: CameraDeviceFactory + let backDevice: CameraDevice beforeAll(async () => { await VisionCamera.requestCameraPermission() expect(VisionCamera.cameraPermissionStatus).toBe('authorized') factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + assert.exists(back, 'no back camera') + backDevice = back }) it('updates useCameraDevice when the requested position changes', async () => { @@ -643,4 +678,79 @@ describe('VisionCamera - Hooks', () => { expect(onError).not.toHaveBeenCalled() }) + + it('stops delivering Frames to useFrameOutput once the component unmounts', async () => { + const onFrameReceived = fn<() => void>() + let hookFrameOutput: CameraFrameOutput | undefined + const onFrameOutput = (frameOutput: CameraFrameOutput) => { + hookFrameOutput = frameOutput + } + + const { unmount } = await render( + , + ) + await waitUntil(() => hookFrameOutput != null, { timeout: 10_000 }) + assert.exists(hookFrameOutput, 'useFrameOutput did not produce an output') + + // The session outlives the component: a real app keeps the pipeline + // running (or re-attaches the same output) after a screen unmounts. + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const onSessionError = fn<(error: Error) => void>() + const errorSub = session.addOnErrorListener(onSessionError) + await session.configure([ + { + input: backDevice, + outputs: [ + { output: hookFrameOutput, mirrorMode: 'auto' }, + { output: photoOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + await session.start() + + try { + await waitUntil( + () => { + const error = onSessionError.mock.lastCall?.[0] + if (error != null) throw error + return onFrameReceived.mock.calls.length >= 3 + }, + { timeout: 15_000 }, + ) + + unmount() + + // A photo capture only completes with the pipeline running, so it is + // the clock: Frames in flight at unmount land before it resolves. + const settlePhoto = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + settlePhoto.dispose() + const framesAfterUnmount = onFrameReceived.mock.calls.length + + const clockPhoto = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(clockPhoto.width).toBeGreaterThan(0) + clockPhoto.dispose() + + expect(onSessionError).not.toHaveBeenCalled() + expect(onFrameReceived).toHaveBeenCalledTimes(framesAfterUnmount) + } finally { + errorSub.remove() + await session.stop() + } + }) }) diff --git a/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts b/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts index f88f1a9d6d..5100008eeb 100644 --- a/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts +++ b/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts @@ -106,6 +106,9 @@ export function useDepthOutput({ if (callback != null) callback(reason) else console.warn(`Depth Frame Dropped! Reason: ${reason}`) }) + return () => { + depthOutput.setOnDepthFrameDroppedCallback(undefined) + } }, [depthOutput]) // 4. Create Worklet Runtime for NativeThread @@ -116,6 +119,9 @@ export function useDepthOutput({ // 5. Update onDepth() callback if it changed useEffect(() => { runtime.setOnDepthFrameCallback(depthOutput, onDepth) + return () => { + runtime.setOnDepthFrameCallback(depthOutput, undefined) + } }, [runtime, depthOutput, onDepth]) // 6. Return :) diff --git a/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts b/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts index dd126ec813..09bc3b45ef 100644 --- a/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts +++ b/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts @@ -165,6 +165,11 @@ export function useFrameOutput({ if (callback != null) callback(reason) else console.warn(`Frame Dropped! Reason: ${reason}`) }) + return () => { + // The native output holds the callback (and everything it closes over) + // as a GC root until it is cleared. + frameOutput.setOnFrameDroppedCallback(undefined) + } }, [frameOutput]) // 4. Create Worklet Runtime for NativeThread @@ -175,6 +180,9 @@ export function useFrameOutput({ // 5. Update onFrame() callback if it changed useEffect(() => { runtime.setOnFrameCallback(frameOutput, onFrame) + return () => { + runtime.setOnFrameCallback(frameOutput, undefined) + } }, [runtime, frameOutput, onFrame]) // 6. Return :) diff --git a/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts b/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts index 3de09ef885..c698c6e122 100644 --- a/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts +++ b/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts @@ -49,6 +49,9 @@ export function useObjectOutput({ // 2. Update onObjectsScanned() callback if it changed useEffect(() => { objectOutput.setOnObjectsScannedCallback(onObjectsScanned) + return () => { + objectOutput.setOnObjectsScannedCallback(undefined) + } }, [objectOutput, onObjectsScanned]) // 3. Return :)