Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/simple-camera/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
110 changes: 110 additions & 0 deletions apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { StyleSheet } from 'react-native'
import {
assert,
beforeAll,
describe,
expect,
Expand All @@ -9,6 +10,7 @@ import {
type Mock,
render,
waitFor,
waitUntil,
} from 'react-native-harness'
import {
Screen,
Expand All @@ -18,6 +20,7 @@ import {
import type {
CameraDevice,
CameraDeviceFactory,
CameraFrameOutput,
CameraOrientation,
CameraPhotoOutput,
CameraPosition,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(
<FrameOutputProbe
onFrameOutput={onFrameOutput}
onFrameReceived={onFrameReceived}
/>,
)
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()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 :)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 :)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 :)
Expand Down