From f9386cdae3881345faf4603303d15d16d4c3eadf Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Tue, 28 Jul 2026 18:51:16 +0200 Subject: [PATCH 1/3] Streamline Harness integration tests --- .../visioncamera.frame-converter.harness.ts | 3 - .../visioncamera.multi-output.harness.ts | 300 ++++++------------ .../__tests__/visioncamera.resizer.harness.ts | 4 - .../__tests__/visioncamera.session.harness.ts | 52 +-- .../__tests__/visioncamera.video.harness.ts | 27 +- 5 files changed, 125 insertions(+), 261 deletions(-) diff --git a/apps/simple-camera/__tests__/visioncamera.frame-converter.harness.ts b/apps/simple-camera/__tests__/visioncamera.frame-converter.harness.ts index c7d0a0a9e7..efa2f5cc84 100644 --- a/apps/simple-camera/__tests__/visioncamera.frame-converter.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.frame-converter.harness.ts @@ -163,9 +163,6 @@ describe('VisionCamera - Frame Converter', () => { } expectRawPixelsToBeEqual(syncPixels, asyncPixels) - console.log( - `Frame Converter: target=${outputOrientation}, frame=${frame.orientation}, mirrored=${frame.isMirrored}, size=${syncPixels.width}x${syncPixels.height}`, - ) } finally { isWaitingForFrame = false runtime.setOnFrameCallback(frameOutput, undefined) diff --git a/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts b/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts index 93912a6431..48fd6b7be1 100644 --- a/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts @@ -225,7 +225,7 @@ describe('VisionCamera - Multi-Output', () => { } }) - it('replaces the photo output while a video + frame output are also attached', async () => { + it('sequentially replaces photo, video, and frame outputs without disrupting untouched outputs', async () => { const session = await VisionCamera.createCameraSession(false) const firstPhotoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, @@ -233,13 +233,13 @@ describe('VisionCamera - Multi-Output', () => { quality: 0.8, qualityPrioritization: 'balanced', }) - const videoOutput = VisionCamera.createVideoOutput({ + const firstVideoOutput = VisionCamera.createVideoOutput({ targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) - const frameOutput = VisionCamera.createFrameOutput({ + const yuvFrameOutput = VisionCamera.createFrameOutput({ targetResolution: CommonResolutions.HD_16_9, - pixelFormat: 'native', + pixelFormat: 'yuv', enablePreviewSizedOutputBuffers: false, enablePhysicalBufferRotation: false, enableCameraMatrixDelivery: false, @@ -247,10 +247,12 @@ describe('VisionCamera - Multi-Output', () => { dropFramesWhileBusy: true, }) - let framesReceived = 0 + let yuvFramesReceived = 0 + let yuvIsPlanar: boolean | undefined let sessionError: Error | undefined - const onFrameReceived = () => { - framesReceived++ + const reportYuvFrame = (isPlanar: boolean) => { + yuvFramesReceived++ + yuvIsPlanar = isPlanar } const errorSub = session.addOnErrorListener((error) => { sessionError = error @@ -261,238 +263,121 @@ describe('VisionCamera - Multi-Output', () => { input: backDevice, outputs: [ { output: firstPhotoOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, - { output: frameOutput, mirrorMode: 'auto' }, + { output: firstVideoOutput, mirrorMode: 'auto' }, + { output: yuvFrameOutput, mirrorMode: 'auto' }, ], constraints: [], }, ]) - const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) - runtime.setOnFrameCallback(frameOutput, (frame) => { + const yuvRuntime = workletsProvider.createRuntimeForThread( + yuvFrameOutput.thread, + ) + yuvRuntime.setOnFrameCallback(yuvFrameOutput, (frame) => { 'worklet' - scheduleOnRN(onFrameReceived) + scheduleOnRN(reportYuvFrame, frame.isPlanar) frame.dispose() }) await session.start() try { + await waitUntil(() => yuvIsPlanar != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + expect(yuvIsPlanar).toBe(true) + + const waitForMoreYuvFrames = async () => { + const framesBefore = yuvFramesReceived + await waitUntil( + () => yuvFramesReceived > framesBefore + 2 || sessionError != null, + { timeout: 15_000 }, + ) + expect(sessionError).toBe(undefined) + expect(yuvFramesReceived).toBeGreaterThan(framesBefore) + } + const secondPhotoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.FHD_4_3, containerFormat: 'jpeg', quality: 0.5, qualityPrioritization: 'quality', }) - await session.configure([ { input: backDevice, outputs: [ { output: secondPhotoOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, - { output: frameOutput, mirrorMode: 'auto' }, + { output: firstVideoOutput, mirrorMode: 'auto' }, + { output: yuvFrameOutput, mirrorMode: 'auto' }, ], constraints: [], }, ]) - // The replacement photo output captures. - const photo = await secondPhotoOutput.capturePhoto( + const photoAfterPhotoReplacement = await secondPhotoOutput.capturePhoto( { flashMode: 'off', enableShutterSound: false }, {}, ) - expect(photo.width).toBeGreaterThan(0) - expect(photo.height).toBeGreaterThan(0) - photo.dispose() - - // The untouched video output still records. - const recorder = await videoOutput.createRecorder({}) - const finished = deferred() - await recorder.startRecording(() => finished.resolve(), finished.reject) + expect(photoAfterPhotoReplacement.width).toBeGreaterThan(0) + expect(photoAfterPhotoReplacement.height).toBeGreaterThan(0) + photoAfterPhotoReplacement.dispose() + + const firstRecorder = await firstVideoOutput.createRecorder({}) + const firstRecordingFinished = deferred() + await firstRecorder.startRecording( + () => firstRecordingFinished.resolve(), + firstRecordingFinished.reject, + ) await sleep(500) - await recorder.stopRecording() - await withTimeout(finished.promise, 15_000, 'finish') - - // The untouched frame output still streams. - const framesAtCheck = framesReceived - await waitUntil( - () => framesReceived > framesAtCheck + 2 || sessionError != null, - { timeout: 15_000 }, + await firstRecorder.stopRecording() + await withTimeout( + firstRecordingFinished.promise, + 15_000, + 'recording after photo replacement', ) - expect(framesReceived).toBeGreaterThan(framesAtCheck) - - expect(sessionError).toBe(undefined) - } finally { - runtime.setOnFrameCallback(frameOutput, undefined) - errorSub.remove() - await session.stop() - } - }) - - it('replaces the video output while a photo + frame output are also attached', async () => { - const session = await VisionCamera.createCameraSession(false) - const photoOutput = VisionCamera.createPhotoOutput({ - targetResolution: CommonResolutions.HD_4_3, - containerFormat: 'jpeg', - quality: 0.8, - qualityPrioritization: 'balanced', - }) - const firstVideoOutput = VisionCamera.createVideoOutput({ - targetResolution: CommonResolutions.HD_16_9, - enableAudio: false, - }) - const frameOutput = VisionCamera.createFrameOutput({ - targetResolution: CommonResolutions.HD_16_9, - pixelFormat: 'native', - enablePreviewSizedOutputBuffers: false, - enablePhysicalBufferRotation: false, - enableCameraMatrixDelivery: false, - allowDeferredStart: false, - dropFramesWhileBusy: true, - }) + await waitForMoreYuvFrames() - let framesReceived = 0 - let sessionError: Error | undefined - const onFrameReceived = () => { - framesReceived++ - } - const errorSub = session.addOnErrorListener((error) => { - sessionError = error - }) - - await session.configure([ - { - input: backDevice, - outputs: [ - { output: photoOutput, mirrorMode: 'auto' }, - { output: firstVideoOutput, mirrorMode: 'auto' }, - { output: frameOutput, mirrorMode: 'auto' }, - ], - constraints: [], - }, - ]) - - const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) - runtime.setOnFrameCallback(frameOutput, (frame) => { - 'worklet' - scheduleOnRN(onFrameReceived) - frame.dispose() - }) - - await session.start() - - try { const secondVideoOutput = VisionCamera.createVideoOutput({ targetResolution: CommonResolutions.FHD_16_9, enableAudio: false, }) - await session.configure([ { input: backDevice, outputs: [ - { output: photoOutput, mirrorMode: 'auto' }, + { output: secondPhotoOutput, mirrorMode: 'auto' }, { output: secondVideoOutput, mirrorMode: 'auto' }, - { output: frameOutput, mirrorMode: 'auto' }, + { output: yuvFrameOutput, mirrorMode: 'auto' }, ], constraints: [], }, ]) - // The replacement video output records. - const recorder = await secondVideoOutput.createRecorder({}) - const finished = deferred() - await recorder.startRecording(() => finished.resolve(), finished.reject) - await sleep(500) - await recorder.stopRecording() - await withTimeout(finished.promise, 15_000, 'finish') - - // The untouched photo output still captures. - const photo = await photoOutput.capturePhoto( + const photoAfterVideoReplacement = await secondPhotoOutput.capturePhoto( { flashMode: 'off', enableShutterSound: false }, {}, ) - expect(photo.width).toBeGreaterThan(0) - expect(photo.height).toBeGreaterThan(0) - photo.dispose() - - // The untouched frame output still streams. - const framesAtCheck = framesReceived - await waitUntil( - () => framesReceived > framesAtCheck + 2 || sessionError != null, - { timeout: 15_000 }, + expect(photoAfterVideoReplacement.width).toBeGreaterThan(0) + expect(photoAfterVideoReplacement.height).toBeGreaterThan(0) + photoAfterVideoReplacement.dispose() + + const recorderAfterVideoReplacement = + await secondVideoOutput.createRecorder({}) + const recordingAfterVideoReplacement = deferred() + await recorderAfterVideoReplacement.startRecording( + () => recordingAfterVideoReplacement.resolve(), + recordingAfterVideoReplacement.reject, ) - expect(framesReceived).toBeGreaterThan(framesAtCheck) - - expect(sessionError).toBe(undefined) - } finally { - runtime.setOnFrameCallback(frameOutput, undefined) - errorSub.remove() - await session.stop() - } - }) - - it('replaces the frame output with a different pixel format while a photo + video output are also attached', async () => { - const session = await VisionCamera.createCameraSession(false) - const photoOutput = VisionCamera.createPhotoOutput({ - targetResolution: CommonResolutions.HD_4_3, - containerFormat: 'jpeg', - quality: 0.8, - qualityPrioritization: 'balanced', - }) - const videoOutput = VisionCamera.createVideoOutput({ - targetResolution: CommonResolutions.HD_16_9, - enableAudio: false, - }) - const yuvFrameOutput = VisionCamera.createFrameOutput({ - targetResolution: CommonResolutions.HD_16_9, - pixelFormat: 'yuv', - enablePreviewSizedOutputBuffers: false, - enablePhysicalBufferRotation: false, - enableCameraMatrixDelivery: false, - allowDeferredStart: false, - dropFramesWhileBusy: true, - }) - - let sessionError: Error | undefined - const errorSub = session.addOnErrorListener((error) => { - sessionError = error - }) - - await session.configure([ - { - input: backDevice, - outputs: [ - { output: photoOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, - { output: yuvFrameOutput, mirrorMode: 'auto' }, - ], - constraints: [], - }, - ]) - - let yuvIsPlanar: boolean | undefined - const reportYuv = (planar: boolean) => { - yuvIsPlanar = planar - } - const yuvRuntime = workletsProvider.createRuntimeForThread( - yuvFrameOutput.thread, - ) - yuvRuntime.setOnFrameCallback(yuvFrameOutput, (frame) => { - 'worklet' - scheduleOnRN(reportYuv, frame.isPlanar) - frame.dispose() - }) - - await session.start() - - try { - await waitUntil(() => yuvIsPlanar != null || sessionError != null, { - timeout: 15_000, - }) - expect(sessionError).toBe(undefined) - expect(yuvIsPlanar).toBe(true) + await sleep(500) + await recorderAfterVideoReplacement.stopRecording() + await withTimeout( + recordingAfterVideoReplacement.promise, + 15_000, + 'recording after video replacement', + ) + await waitForMoreYuvFrames() yuvRuntime.setOnFrameCallback(yuvFrameOutput, undefined) @@ -505,13 +390,12 @@ describe('VisionCamera - Multi-Output', () => { allowDeferredStart: false, dropFramesWhileBusy: true, }) - await session.configure([ { input: backDevice, outputs: [ - { output: photoOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, + { output: secondPhotoOutput, mirrorMode: 'auto' }, + { output: secondVideoOutput, mirrorMode: 'auto' }, { output: rgbFrameOutput, mirrorMode: 'auto' }, ], constraints: [], @@ -519,8 +403,8 @@ describe('VisionCamera - Multi-Output', () => { ]) let rgbIsPlanar: boolean | undefined - const reportRgb = (planar: boolean) => { - rgbIsPlanar = planar + const reportRgb = (isPlanar: boolean) => { + rgbIsPlanar = isPlanar } const rgbRuntime = workletsProvider.createRuntimeForThread( rgbFrameOutput.thread, @@ -530,7 +414,6 @@ describe('VisionCamera - Multi-Output', () => { scheduleOnRN(reportRgb, frame.isPlanar) frame.dispose() }) - try { await waitUntil(() => rgbIsPlanar != null || sessionError != null, { timeout: 15_000, @@ -538,26 +421,33 @@ describe('VisionCamera - Multi-Output', () => { expect(sessionError).toBe(undefined) expect(rgbIsPlanar).toBe(false) - // The untouched photo output still captures. - const photo = await photoOutput.capturePhoto( + const photoAfterFrameReplacement = await secondPhotoOutput.capturePhoto( { flashMode: 'off', enableShutterSound: false }, {}, ) - expect(photo.width).toBeGreaterThan(0) - expect(photo.height).toBeGreaterThan(0) - photo.dispose() - - // The untouched video output still records. - const recorder = await videoOutput.createRecorder({}) - const finished = deferred() - await recorder.startRecording(() => finished.resolve(), finished.reject) + expect(photoAfterFrameReplacement.width).toBeGreaterThan(0) + expect(photoAfterFrameReplacement.height).toBeGreaterThan(0) + photoAfterFrameReplacement.dispose() + + const secondRecorder = await secondVideoOutput.createRecorder({}) + const secondRecordingFinished = deferred() + await secondRecorder.startRecording( + () => secondRecordingFinished.resolve(), + secondRecordingFinished.reject, + ) await sleep(500) - await recorder.stopRecording() - await withTimeout(finished.promise, 15_000, 'finish') + await secondRecorder.stopRecording() + await withTimeout( + secondRecordingFinished.promise, + 15_000, + 'recording after frame replacement', + ) + expect(sessionError).toBe(undefined) } finally { rgbRuntime.setOnFrameCallback(rgbFrameOutput, undefined) } } finally { + yuvRuntime.setOnFrameCallback(yuvFrameOutput, undefined) errorSub.remove() await session.stop() } diff --git a/apps/simple-camera/__tests__/visioncamera.resizer.harness.ts b/apps/simple-camera/__tests__/visioncamera.resizer.harness.ts index 649453890b..e7856ef3d4 100644 --- a/apps/simple-camera/__tests__/visioncamera.resizer.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.resizer.harness.ts @@ -256,10 +256,6 @@ describe('VisionCamera - Resizer', () => { resized.dispose() } } - - console.log( - `Resizer: target=${outputOrientation}, frame=${frame.orientation}, mirrored=${frame.isMirrored}`, - ) } finally { isWaitingForFrame = false runtime.setOnFrameCallback(frameOutput, undefined) diff --git a/apps/simple-camera/__tests__/visioncamera.session.harness.ts b/apps/simple-camera/__tests__/visioncamera.session.harness.ts index f942cf95d1..e59ed8dd68 100644 --- a/apps/simple-camera/__tests__/visioncamera.session.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.session.harness.ts @@ -348,33 +348,35 @@ describe('VisionCamera - Session', () => { qualityPrioritization: 'balanced', }) - await session.configure([ - { - input: device, - outputs: [{ output: photoOutput, mirrorMode: 'auto' }], - constraints: [], - }, - ]) - await session.start() - - const videoOutput = VisionCamera.createVideoOutput({ - targetResolution: CommonResolutions.HD_16_9, - enableAudio: false, - }) + try { + await session.configure([ + { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() - const controllers = await session.configure([ - { - input: device, - outputs: [ - { output: photoOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, - ], - constraints: [], - }, - ]) - expect(controllers).toHaveLength(1) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) - await session.stop() + const controllers = await session.configure([ + { + input: device, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + expect(controllers).toHaveLength(1) + } finally { + await session.stop() + } }) it('supports a multi-cam session when the platform allows it', async (context) => { diff --git a/apps/simple-camera/__tests__/visioncamera.video.harness.ts b/apps/simple-camera/__tests__/visioncamera.video.harness.ts index c94b0286c1..5e37c792f8 100644 --- a/apps/simple-camera/__tests__/visioncamera.video.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.video.harness.ts @@ -650,31 +650,9 @@ describe('VisionCamera - Video', () => { } }) - it('returns supported video codecs on iOS after the output is attached', async (context) => { + it('accepts advertised video output settings on iOS', async (context) => { if (Platform.OS !== 'ios') { - return context.skip('getSupportedVideoCodecs: iOS only') - } - const session = await VisionCamera.createCameraSession(false) - const videoOutput = VisionCamera.createVideoOutput({ - targetResolution: CommonResolutions.HD_16_9, - enableAudio: false, - }) - await session.configure([ - { - input: backDevice, - outputs: [{ output: videoOutput, mirrorMode: 'auto' }], - constraints: [], - }, - ]) - const codecs = videoOutput.getSupportedVideoCodecs() - expect(codecs.length).toBeGreaterThan(0) - expect(codecs).not.toContain('unknown') - await session.stop() - }) - - it('applies video output settings on iOS after the output is attached', async (context) => { - if (Platform.OS !== 'ios') { - return context.skip('setOutputSettings: iOS only') + return context.skip('video output settings: iOS only') } const session = await VisionCamera.createCameraSession(false) const videoOutput = VisionCamera.createVideoOutput({ @@ -694,6 +672,7 @@ describe('VisionCamera - Video', () => { const codecs = videoOutput.getSupportedVideoCodecs() expect(codecs.length).toBeGreaterThan(0) + expect(codecs).not.toContain('unknown') for (const codec of codecs) { await videoOutput.setOutputSettings({ codec }) } From 71396ea7f52893224075ee9fa503c9913dafbc10 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Tue, 28 Jul 2026 19:19:17 +0200 Subject: [PATCH 2/3] Exercise constraints in running camera sessions --- .../visioncamera.constraints.harness.ts | 741 ++++++++++++------ 1 file changed, 481 insertions(+), 260 deletions(-) diff --git a/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts index c05f3b67a1..32ec03cdd6 100644 --- a/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts @@ -8,6 +8,8 @@ import { import type { CameraDevice, CameraDeviceFactory, + CameraOutputConfiguration, + CameraSession, CameraSessionConfig, Constraint, } from 'react-native-vision-camera' @@ -31,6 +33,116 @@ describe('VisionCamera - Constraints', () => { backDevice = back }) + async function withStartedSession( + session: CameraSession, + run: (throwIfSessionError: () => void) => Promise, + ): Promise { + let didStart = false + let sessionError: Error | undefined + const throwIfSessionError = () => { + if (sessionError != null) throw sessionError + } + const startedSub = session.addOnStartedListener(() => { + didStart = true + }) + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + try { + await session.start() + await waitUntil( + () => { + throwIfSessionError() + return didStart + }, + { timeout: 10_000 }, + ) + + const result = await run(throwIfSessionError) + throwIfSessionError() + return result + } finally { + startedSub.remove() + errorSub.remove() + } + } + + async function waitForOutputsToAttach( + outputs: CameraOutputConfiguration[], + throwIfSessionError: () => void, + ) { + await waitUntil( + () => { + throwIfSessionError() + return outputs.every(({ output }) => output.currentResolution != null) + }, + { timeout: 10_000 }, + ) + } + + function expectConfigsToMatch( + sessionConfig: CameraSessionConfig, + resolvedConfig: CameraSessionConfig, + ) { + expect(sessionConfig.selectedFPS).toBe(resolvedConfig.selectedFPS) + expect(sessionConfig.selectedVideoStabilizationMode).toBe( + resolvedConfig.selectedVideoStabilizationMode, + ) + expect(sessionConfig.selectedPreviewStabilizationMode).toBe( + resolvedConfig.selectedPreviewStabilizationMode, + ) + expect(sessionConfig.selectedVideoDynamicRange).toEqual( + resolvedConfig.selectedVideoDynamicRange, + ) + expect(sessionConfig.isPhotoHDREnabled).toBe( + resolvedConfig.isPhotoHDREnabled, + ) + expect(sessionConfig.nativePixelFormat).toBe( + resolvedConfig.nativePixelFormat, + ) + expect(sessionConfig.autoFocusSystem).toBe(resolvedConfig.autoFocusSystem) + expect(sessionConfig.isBinned).toBe(resolvedConfig.isBinned) + } + + async function withRunningConstraintSession( + device: CameraDevice, + outputs: CameraOutputConfiguration[], + constraints: Constraint[], + assertConfig: (config: CameraSessionConfig) => void | Promise, + previouslyResolvedConfig?: CameraSessionConfig, + ) { + const resolvedConfig = + previouslyResolvedConfig ?? + (await VisionCamera.resolveConstraints(device, outputs, constraints)) + const session = await VisionCamera.createCameraSession(false) + let selectedConfig: CameraSessionConfig | undefined + + try { + await session.configure([ + { + input: device, + outputs, + constraints, + onSessionConfigSelected: (config) => { + selectedConfig = config + }, + }, + ]) + await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) + if (selectedConfig == null) throw new Error('no selected config') + expectConfigsToMatch(selectedConfig, resolvedConfig) + + await withStartedSession(session, async (throwIfSessionError) => { + await waitForOutputsToAttach(outputs, throwIfSessionError) + if (selectedConfig == null) throw new Error('no selected config') + await assertConfig(selectedConfig) + }) + } finally { + await session.stop() + } + } + it('resolves a baseline config with no constraints', async () => { const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, @@ -47,35 +159,21 @@ describe('VisionCamera - Constraints', () => { expect(backDevice.supportedPixelFormats).toContain(config.nativePixelFormat) }) - it('resolves an explicit fps: 30 constraint', async () => { + it('configures and starts an explicit fps: 30 constraint', async () => { const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, containerFormat: 'jpeg', quality: 0.8, qualityPrioritization: 'balanced', }) - const config = await VisionCamera.resolveConstraints( + await withRunningConstraintSession( backDevice, [{ output: photoOutput, mirrorMode: 'auto' }], [{ fps: 30 }], + (config) => { + expect(config.selectedFPS).toBe(30) + }, ) - expect(config.selectedFPS).toBe(30) - }) - - it('resolves a fps: 60 constraint if the device supports it', async (context) => { - if (!backDevice.supportsFPS(60)) { - return context.skip('fps: 60 not supported on this device') - } - const videoOutput = VisionCamera.createVideoOutput({ - targetResolution: CommonResolutions.HD_16_9, - enableAudio: false, - }) - const config = await VisionCamera.resolveConstraints( - backDevice, - [{ output: videoOutput, mirrorMode: 'auto' }], - [{ fps: 60 }], - ) - expect(config.selectedFPS).toBe(60) }) it('keeps 4k video at 60 fps when a preview output is attached', async (context) => { @@ -112,45 +210,50 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined + const outputs = + previewOutput == null + ? [{ output: videoOutput, mirrorMode: 'auto' as const }] + : [ + { output: previewOutput, mirrorMode: 'auto' as const }, + { output: videoOutput, mirrorMode: 'auto' as const }, + ] + const constraints: Constraint[] = + previewOutput == null + ? [{ fps: 60 }, { resolutionBias: videoOutput }] + : [ + { fps: 60 }, + { resolutionBias: previewOutput }, + { resolutionBias: videoOutput }, + ] let selectedConfig: CameraSessionConfig | undefined - await session.configure([ - { - input: backDevice, - outputs: - previewOutput == null - ? [{ output: videoOutput, mirrorMode: 'auto' }] - : [ - { output: previewOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, - ], - constraints: - previewOutput == null - ? [{ fps: 60 }, { resolutionBias: videoOutput }] - : [ - { fps: 60 }, - { resolutionBias: previewOutput }, - { resolutionBias: videoOutput }, - ], - onSessionConfigSelected: (config) => { - selectedConfig = config - }, - }, - ]) - await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) - - await session.start() try { - await waitUntil(() => videoOutput.currentResolution != null, { - timeout: 10_000, - }) - const currentResolution = videoOutput.currentResolution - if (selectedConfig == null) throw new Error('no selected config') - if (currentResolution == null) throw new Error('no video resolution') - return { - selectedFPS: selectedConfig.selectedFPS, - resolution: currentResolution, - } + await session.configure([ + { + input: backDevice, + outputs, + constraints, + onSessionConfigSelected: (config) => { + selectedConfig = config + }, + }, + ]) + await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) + + return await withStartedSession( + session, + async (throwIfSessionError) => { + await waitForOutputsToAttach(outputs, throwIfSessionError) + const currentResolution = videoOutput.currentResolution + if (selectedConfig == null) throw new Error('no selected config') + if (currentResolution == null) + throw new Error('no video resolution') + return { + selectedFPS: selectedConfig.selectedFPS, + resolution: currentResolution, + } + }, + ) } finally { await session.stop() } @@ -236,35 +339,37 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined + const outputs = + previewOutput == null + ? [{ output: videoOutput, mirrorMode: 'auto' as const }] + : [ + { output: previewOutput, mirrorMode: 'auto' as const }, + { output: videoOutput, mirrorMode: 'auto' as const }, + ] + const constraints: Constraint[] = + previewOutput == null + ? [{ resolutionBias: videoOutput }] + : [{ resolutionBias: previewOutput }, { resolutionBias: videoOutput }] - await session.configure([ - { - input: backDevice, - outputs: - previewOutput == null - ? [{ output: videoOutput, mirrorMode: 'auto' }] - : [ - { output: previewOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, - ], - constraints: - previewOutput == null - ? [{ resolutionBias: videoOutput }] - : [ - { resolutionBias: previewOutput }, - { resolutionBias: videoOutput }, - ], - }, - ]) - - await session.start() try { - await waitUntil(() => videoOutput.currentResolution != null, { - timeout: 10_000, - }) - const currentResolution = videoOutput.currentResolution - if (currentResolution == null) throw new Error('no video resolution') - return currentResolution + await session.configure([ + { + input: backDevice, + outputs, + constraints, + }, + ]) + + return await withStartedSession( + session, + async (throwIfSessionError) => { + await waitForOutputsToAttach(outputs, throwIfSessionError) + const currentResolution = videoOutput.currentResolution + if (currentResolution == null) + throw new Error('no video resolution') + return currentResolution + }, + ) } finally { await session.stop() } @@ -321,49 +426,54 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined + const outputs = + previewOutput == null + ? [{ output: videoOutput, mirrorMode: 'auto' as const }] + : [ + { output: previewOutput, mirrorMode: 'auto' as const }, + { output: videoOutput, mirrorMode: 'auto' as const }, + ] + const constraints: Constraint[] = + previewOutput == null + ? [{ fps: 30 }, { resolutionBias: videoOutput }] + : [ + { fps: 30 }, + { resolutionBias: videoOutput }, + { resolutionBias: previewOutput }, + { resolutionBias: videoOutput }, + ] let selectedConfig: CameraSessionConfig | undefined // The same constraints list `useCameraController` builds: the user's // explicit constraints first, then one auto-appended resolutionBias // per output (preview is listed as the first output by ). - await session.configure([ - { - input: backDevice, - outputs: - previewOutput == null - ? [{ output: videoOutput, mirrorMode: 'auto' }] - : [ - { output: previewOutput, mirrorMode: 'auto' }, - { output: videoOutput, mirrorMode: 'auto' }, - ], - constraints: - previewOutput == null - ? [{ fps: 30 }, { resolutionBias: videoOutput }] - : [ - { fps: 30 }, - { resolutionBias: videoOutput }, - { resolutionBias: previewOutput }, - { resolutionBias: videoOutput }, - ], - onSessionConfigSelected: (config) => { - selectedConfig = config - }, - }, - ]) - await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) - - await session.start() try { - await waitUntil(() => videoOutput.currentResolution != null, { - timeout: 10_000, - }) - const currentResolution = videoOutput.currentResolution - if (selectedConfig == null) throw new Error('no selected config') - if (currentResolution == null) throw new Error('no video resolution') - return { - selectedFPS: selectedConfig.selectedFPS, - resolution: currentResolution, - } + await session.configure([ + { + input: backDevice, + outputs, + constraints, + onSessionConfigSelected: (config) => { + selectedConfig = config + }, + }, + ]) + await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) + + return await withStartedSession( + session, + async (throwIfSessionError) => { + await waitForOutputsToAttach(outputs, throwIfSessionError) + const currentResolution = videoOutput.currentResolution + if (selectedConfig == null) throw new Error('no selected config') + if (currentResolution == null) + throw new Error('no video resolution') + return { + selectedFPS: selectedConfig.selectedFPS, + resolution: currentResolution, + } + }, + ) } finally { await session.stop() } @@ -406,35 +516,37 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined + const outputs = + previewOutput == null + ? [{ output: photoOutput, mirrorMode: 'auto' as const }] + : [ + { output: previewOutput, mirrorMode: 'auto' as const }, + { output: photoOutput, mirrorMode: 'auto' as const }, + ] + const constraints: Constraint[] = + previewOutput == null + ? [{ resolutionBias: photoOutput }] + : [{ resolutionBias: photoOutput }, { resolutionBias: previewOutput }] - await session.configure([ - { - input: backDevice, - outputs: - previewOutput == null - ? [{ output: photoOutput, mirrorMode: 'auto' }] - : [ - { output: previewOutput, mirrorMode: 'auto' }, - { output: photoOutput, mirrorMode: 'auto' }, - ], - constraints: - previewOutput == null - ? [{ resolutionBias: photoOutput }] - : [ - { resolutionBias: photoOutput }, - { resolutionBias: previewOutput }, - ], - }, - ]) - - await session.start() try { - await waitUntil(() => photoOutput.currentResolution != null, { - timeout: 10_000, - }) - const currentResolution = photoOutput.currentResolution - if (currentResolution == null) throw new Error('no photo resolution') - return currentResolution + await session.configure([ + { + input: backDevice, + outputs, + constraints, + }, + ]) + + return await withStartedSession( + session, + async (throwIfSessionError) => { + await waitForOutputsToAttach(outputs, throwIfSessionError) + const currentResolution = photoOutput.currentResolution + if (currentResolution == null) + throw new Error('no photo resolution') + return currentResolution + }, + ) } finally { await session.stop() } @@ -449,7 +561,7 @@ describe('VisionCamera - Constraints', () => { expect(withPreviewEdges.long).toBe(photoOnlyEdges.long) }) - it('resolves photoHDR: true when the device supports photo HDR', async (context) => { + it('configures and starts photoHDR: true when supported', async (context) => { if (!backDevice.supportsPhotoHDR) { return context.skip('photoHDR: not supported on this device') } @@ -459,15 +571,31 @@ describe('VisionCamera - Constraints', () => { quality: 0.8, qualityPrioritization: 'balanced', }) - const config = await VisionCamera.resolveConstraints( + const outputs = [{ output: photoOutput, mirrorMode: 'auto' as const }] + const constraints: Constraint[] = [ + { resolutionBias: photoOutput }, + { photoHDR: true }, + ] + const resolvedConfig = await VisionCamera.resolveConstraints( backDevice, - [{ output: photoOutput, mirrorMode: 'auto' }], - [{ resolutionBias: photoOutput }, { photoHDR: true }], + outputs, + constraints, + ) + if (!resolvedConfig.isPhotoHDREnabled) { + return context.skip('photoHDR: exact output graph resolves without HDR') + } + await withRunningConstraintSession( + backDevice, + outputs, + constraints, + (config) => { + expect(config.isPhotoHDREnabled).toBe(true) + }, + resolvedConfig, ) - expect(config.isPhotoHDREnabled).toBe(true) }) - it('resolves a HDR video dynamic range when the device supports it', async (context) => { + it('configures and starts an HDR video dynamic range when supported', async (context) => { const hasHdr = backDevice.supportedVideoDynamicRanges.some( (d) => d.bitDepth === 'hdr-10-bit', ) @@ -478,117 +606,182 @@ describe('VisionCamera - Constraints', () => { targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) - const config = await VisionCamera.resolveConstraints( + const outputs = [{ output: videoOutput, mirrorMode: 'auto' as const }] + const constraints: Constraint[] = [ + { videoDynamicRange: CommonDynamicRanges.ANY_HDR }, + { resolutionBias: videoOutput }, + ] + const resolvedConfig = await VisionCamera.resolveConstraints( backDevice, - [{ output: videoOutput, mirrorMode: 'auto' }], - [ - { videoDynamicRange: CommonDynamicRanges.ANY_HDR }, - { resolutionBias: videoOutput }, - ], + outputs, + constraints, + ) + if (resolvedConfig.selectedVideoDynamicRange?.bitDepth !== 'hdr-10-bit') { + return context.skip( + 'video HDR: exact output graph resolves without an HDR dynamic range', + ) + } + await withRunningConstraintSession( + backDevice, + outputs, + constraints, + (config) => { + expect(config.selectedVideoDynamicRange?.bitDepth).toBe('hdr-10-bit') + }, + resolvedConfig, ) - expect(config.selectedVideoDynamicRange?.bitDepth).toBe('hdr-10-bit') }) - it('resolves a video stabilization constraint when supported', async (context) => { - // The resolver downgrades stabilization modes (cinematic → standard → off) - // when the requested one isn't supported, so picking the most demanding - // mode the device exposes gives the test the most coverage. - const stabDevice = factory.cameraDevices.find((d) => - d.supportsVideoStabilizationMode('cinematic'), - ) - if (stabDevice == null) { + it('configures and starts a video stabilization constraint when supported', async (context) => { + const modes = [ + 'cinematic-extended-enhanced', + 'cinematic-extended', + 'cinematic', + 'standard', + 'low-latency', + ] as const + let selection: + | { + device: CameraDevice + mode: (typeof modes)[number] + } + | undefined + for (const mode of modes) { + const device = factory.cameraDevices.find((candidate) => + candidate.supportsVideoStabilizationMode(mode), + ) + if (device != null) { + selection = { device, mode } + break + } + } + if (selection == null) { return context.skip( - 'videoStabilizationMode: no device on this system supports "cinematic"', + 'videoStabilizationMode: no device supports an explicit mode', ) } + const { device, mode } = selection const videoOutput = VisionCamera.createVideoOutput({ targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) - const config = await VisionCamera.resolveConstraints( - stabDevice, - [{ output: videoOutput, mirrorMode: 'auto' }], - [{ videoStabilizationMode: 'cinematic' }], - ) - expect(config.selectedVideoStabilizationMode).toBe('cinematic') - }) - - it('resolves a preview stabilization constraint when supported', async (context) => { - const stabDevice = factory.cameraDevices.find((d) => - d.supportsPreviewStabilizationMode('preview-optimized'), + const outputs = [{ output: videoOutput, mirrorMode: 'auto' as const }] + const constraints: Constraint[] = [{ videoStabilizationMode: mode }] + const resolvedConfig = await VisionCamera.resolveConstraints( + device, + outputs, + constraints, ) - if (stabDevice == null) { + if (resolvedConfig.selectedVideoStabilizationMode !== mode) { return context.skip( - 'previewStabilizationMode: no device on this system supports "preview-optimized"', + `videoStabilizationMode: graph resolves ${mode} to ${resolvedConfig.selectedVideoStabilizationMode ?? 'default'}`, ) } - const previewOutput = VisionCamera.createPreviewOutput() - const config = await VisionCamera.resolveConstraints( - stabDevice, - [{ output: previewOutput, mirrorMode: 'auto' }], - [{ previewStabilizationMode: 'preview-optimized' }], + await withRunningConstraintSession( + device, + outputs, + constraints, + (config) => { + expect(config.selectedVideoStabilizationMode).toBe(mode) + }, + resolvedConfig, ) - expect(config.selectedPreviewStabilizationMode).toBe('preview-optimized') }) - it('resolves a binned: true constraint when supported', async (context) => { + it('configures and starts a preview stabilization constraint when supported', async (context) => { + const modes = [ + 'preview-optimized', + 'cinematic-extended-enhanced', + 'cinematic-extended', + 'cinematic', + 'standard', + 'low-latency', + ] as const + let selection: + | { + device: CameraDevice + mode: (typeof modes)[number] + } + | undefined + for (const mode of modes) { + const device = factory.cameraDevices.find((candidate) => + candidate.supportsPreviewStabilizationMode(mode), + ) + if (device != null) { + selection = { device, mode } + break + } + } + if (selection == null) { + return context.skip( + 'previewStabilizationMode: no device supports an explicit mode', + ) + } + const { device, mode } = selection + const previewOutput = VisionCamera.createPreviewOutput() const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, containerFormat: 'jpeg', quality: 0.8, qualityPrioritization: 'balanced', }) - const config = await VisionCamera.resolveConstraints( - backDevice, - [{ output: photoOutput, mirrorMode: 'auto' }], - [{ binned: true }], + const outputs = [ + { output: previewOutput, mirrorMode: 'auto' as const }, + { output: photoOutput, mirrorMode: 'auto' as const }, + ] + const constraints: Constraint[] = [ + { previewStabilizationMode: mode }, + { resolutionBias: photoOutput }, + ] + const resolvedConfig = await VisionCamera.resolveConstraints( + device, + outputs, + constraints, ) - if (config.isBinned !== true) { + if (resolvedConfig.selectedPreviewStabilizationMode !== mode) { return context.skip( - `binned: true: device resolved to isBinned=${config.isBinned}`, + `previewStabilizationMode: graph resolves ${mode} to ${resolvedConfig.selectedPreviewStabilizationMode ?? 'default'}`, ) } - expect(config.isBinned).toBe(true) + await withRunningConstraintSession( + device, + outputs, + constraints, + (config) => { + expect(config.selectedPreviewStabilizationMode).toBe(mode) + }, + resolvedConfig, + ) }) - it('resolves the same config via VisionCamera.resolveConstraints and session.configure', async () => { + it('configures and starts binned: true when supported', async (context) => { const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, containerFormat: 'jpeg', quality: 0.8, qualityPrioritization: 'balanced', }) - const outputConfig = { - output: photoOutput, - mirrorMode: 'auto' as const, - } - const constraints: Constraint[] = [{ fps: 30 }] - - const standalone = await VisionCamera.resolveConstraints( + const outputs = [{ output: photoOutput, mirrorMode: 'auto' as const }] + const constraints: Constraint[] = [{ binned: true }] + const resolvedConfig = await VisionCamera.resolveConstraints( backDevice, - [outputConfig], + outputs, constraints, ) - - const session = await VisionCamera.createCameraSession(false) - let sessionConfig: CameraSessionConfig | undefined - await session.configure([ - { - input: backDevice, - outputs: [outputConfig], - constraints, - onSessionConfigSelected: (config) => { - sessionConfig = config - }, + if (resolvedConfig.isBinned !== true) { + return context.skip( + `binned: true: device resolved to isBinned=${resolvedConfig.isBinned}`, + ) + } + await withRunningConstraintSession( + backDevice, + outputs, + constraints, + (config) => { + expect(config.isBinned).toBe(true) }, - ]) - await waitUntil(() => sessionConfig != null, { timeout: 5_000 }) - expect(sessionConfig?.selectedFPS).toBe(standalone.selectedFPS) - expect(sessionConfig?.nativePixelFormat).toBe(standalone.nativePixelFormat) - expect(sessionConfig?.isPhotoHDREnabled).toBe(standalone.isPhotoHDREnabled) - expect(sessionConfig?.isBinned).toBe(standalone.isBinned) - - await session.stop() + resolvedConfig, + ) }) // Verifies the resolver's priority mechanism by running the same pair of @@ -600,7 +793,8 @@ describe('VisionCamera - Constraints', () => { // This catches regressions like "resolver always drops the first constraint // instead of the last" or "priority order is silently reversed", without // depending on which feature combinations the AWS Device Farm device happens - // to support together. + // to support together. This stays resolver-only because the individual HDR + // and stabilization constraints above already cross-check a running session. it('honors constraint priority ordering between stabilization and HDR', async (context) => { let chosenStabilizationMode: 'cinematic' | 'standard' | undefined for (const mode of ['cinematic', 'standard'] as const) { @@ -653,57 +847,84 @@ describe('VisionCamera - Constraints', () => { }) it('reconfigures the running session with a different constraint set', async (context) => { - if (!backDevice.supportsFPS(60)) { - return context.skip( - 'reconfigure with new constraints: fps: 60 not supported', - ) - } - - const session = await VisionCamera.createCameraSession(false) const videoOutput = VisionCamera.createVideoOutput({ targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) + const outputs = [{ output: videoOutput, mirrorMode: 'auto' as const }] + const firstConstraints: Constraint[] = [{ fps: 30 }] + const secondConstraints: Constraint[] = [{ fps: 60 }] + const firstResolvedConfig = await VisionCamera.resolveConstraints( + backDevice, + outputs, + firstConstraints, + ) + const secondResolvedConfig = await VisionCamera.resolveConstraints( + backDevice, + outputs, + secondConstraints, + ) + if ( + firstResolvedConfig.selectedFPS !== 30 || + secondResolvedConfig.selectedFPS !== 60 + ) { + return context.skip( + `graph resolves 30→60 fps to ${firstResolvedConfig.selectedFPS ?? 'default'}→${secondResolvedConfig.selectedFPS ?? 'default'} fps`, + ) + } - let sessionError: Error | undefined - const errorSub = session.addOnErrorListener((error) => { - sessionError = error - }) - + const session = await VisionCamera.createCameraSession(false) let firstConfig: CameraSessionConfig | undefined - await session.configure([ - { - input: backDevice, - outputs: [{ output: videoOutput, mirrorMode: 'auto' }], - constraints: [{ fps: 30 }], - onSessionConfigSelected: (config) => { - firstConfig = config - }, - }, - ]) - await waitUntil(() => firstConfig != null, { timeout: 5_000 }) - expect(firstConfig?.selectedFPS).toBe(30) - - await session.start() - try { - let secondConfig: CameraSessionConfig | undefined await session.configure([ { input: backDevice, - outputs: [{ output: videoOutput, mirrorMode: 'auto' }], - constraints: [{ fps: 60 }], + outputs, + constraints: firstConstraints, onSessionConfigSelected: (config) => { - secondConfig = config + firstConfig = config }, }, ]) - await waitUntil(() => secondConfig != null, { timeout: 5_000 }) - expect(secondConfig?.selectedFPS).toBe(60) - - expect(sessionError).toBe(undefined) + await waitUntil(() => firstConfig != null, { timeout: 5_000 }) + if (firstConfig == null) throw new Error('no first selected config') + expectConfigsToMatch(firstConfig, firstResolvedConfig) + expect(firstConfig.selectedFPS).toBe(30) + + await withStartedSession(session, async (throwIfSessionError) => { + await waitForOutputsToAttach(outputs, throwIfSessionError) + + let secondConfig: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs, + constraints: secondConstraints, + onSessionConfigSelected: (config) => { + secondConfig = config + }, + }, + ]) + await waitUntil( + () => { + throwIfSessionError() + return secondConfig != null + }, + { timeout: 5_000 }, + ) + if (secondConfig == null) throw new Error('no second selected config') + expectConfigsToMatch(secondConfig, secondResolvedConfig) + expect(secondConfig.selectedFPS).toBe(60) + + await waitUntil( + () => { + throwIfSessionError() + return session.isRunning + }, + { timeout: 10_000 }, + ) + }) } finally { - errorSub.remove() await session.stop() } }) From 053f2945896bd163c2e980a5e262fd365929357f Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 29 Jul 2026 15:00:36 +0200 Subject: [PATCH 3/3] Simplify camera Harness tests --- .../visioncamera.constraints.harness.ts | 813 +++++++----------- .../visioncamera.multi-output.harness.ts | 314 ++++--- .../__tests__/visioncamera.video.harness.ts | 17 +- 3 files changed, 553 insertions(+), 591 deletions(-) diff --git a/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts index 32ec03cdd6..0d2c218971 100644 --- a/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts @@ -8,8 +8,6 @@ import { import type { CameraDevice, CameraDeviceFactory, - CameraOutputConfiguration, - CameraSession, CameraSessionConfig, Constraint, } from 'react-native-vision-camera' @@ -33,116 +31,6 @@ describe('VisionCamera - Constraints', () => { backDevice = back }) - async function withStartedSession( - session: CameraSession, - run: (throwIfSessionError: () => void) => Promise, - ): Promise { - let didStart = false - let sessionError: Error | undefined - const throwIfSessionError = () => { - if (sessionError != null) throw sessionError - } - const startedSub = session.addOnStartedListener(() => { - didStart = true - }) - const errorSub = session.addOnErrorListener((error) => { - sessionError = error - }) - - try { - await session.start() - await waitUntil( - () => { - throwIfSessionError() - return didStart - }, - { timeout: 10_000 }, - ) - - const result = await run(throwIfSessionError) - throwIfSessionError() - return result - } finally { - startedSub.remove() - errorSub.remove() - } - } - - async function waitForOutputsToAttach( - outputs: CameraOutputConfiguration[], - throwIfSessionError: () => void, - ) { - await waitUntil( - () => { - throwIfSessionError() - return outputs.every(({ output }) => output.currentResolution != null) - }, - { timeout: 10_000 }, - ) - } - - function expectConfigsToMatch( - sessionConfig: CameraSessionConfig, - resolvedConfig: CameraSessionConfig, - ) { - expect(sessionConfig.selectedFPS).toBe(resolvedConfig.selectedFPS) - expect(sessionConfig.selectedVideoStabilizationMode).toBe( - resolvedConfig.selectedVideoStabilizationMode, - ) - expect(sessionConfig.selectedPreviewStabilizationMode).toBe( - resolvedConfig.selectedPreviewStabilizationMode, - ) - expect(sessionConfig.selectedVideoDynamicRange).toEqual( - resolvedConfig.selectedVideoDynamicRange, - ) - expect(sessionConfig.isPhotoHDREnabled).toBe( - resolvedConfig.isPhotoHDREnabled, - ) - expect(sessionConfig.nativePixelFormat).toBe( - resolvedConfig.nativePixelFormat, - ) - expect(sessionConfig.autoFocusSystem).toBe(resolvedConfig.autoFocusSystem) - expect(sessionConfig.isBinned).toBe(resolvedConfig.isBinned) - } - - async function withRunningConstraintSession( - device: CameraDevice, - outputs: CameraOutputConfiguration[], - constraints: Constraint[], - assertConfig: (config: CameraSessionConfig) => void | Promise, - previouslyResolvedConfig?: CameraSessionConfig, - ) { - const resolvedConfig = - previouslyResolvedConfig ?? - (await VisionCamera.resolveConstraints(device, outputs, constraints)) - const session = await VisionCamera.createCameraSession(false) - let selectedConfig: CameraSessionConfig | undefined - - try { - await session.configure([ - { - input: device, - outputs, - constraints, - onSessionConfigSelected: (config) => { - selectedConfig = config - }, - }, - ]) - await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) - if (selectedConfig == null) throw new Error('no selected config') - expectConfigsToMatch(selectedConfig, resolvedConfig) - - await withStartedSession(session, async (throwIfSessionError) => { - await waitForOutputsToAttach(outputs, throwIfSessionError) - if (selectedConfig == null) throw new Error('no selected config') - await assertConfig(selectedConfig) - }) - } finally { - await session.stop() - } - } - it('resolves a baseline config with no constraints', async () => { const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, @@ -159,21 +47,35 @@ describe('VisionCamera - Constraints', () => { expect(backDevice.supportedPixelFormats).toContain(config.nativePixelFormat) }) - it('configures and starts an explicit fps: 30 constraint', async () => { + it('resolves an explicit fps: 30 constraint', async () => { const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, containerFormat: 'jpeg', quality: 0.8, qualityPrioritization: 'balanced', }) - await withRunningConstraintSession( + const config = await VisionCamera.resolveConstraints( backDevice, [{ output: photoOutput, mirrorMode: 'auto' }], [{ fps: 30 }], - (config) => { - expect(config.selectedFPS).toBe(30) - }, ) + expect(config.selectedFPS).toBe(30) + }) + + it('resolves a fps: 60 constraint if the device supports it', async (context) => { + if (!backDevice.supportsFPS(60)) { + return context.skip('fps: 60 not supported on this device') + } + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const config = await VisionCamera.resolveConstraints( + backDevice, + [{ output: videoOutput, mirrorMode: 'auto' }], + [{ fps: 60 }], + ) + expect(config.selectedFPS).toBe(60) }) it('keeps 4k video at 60 fps when a preview output is attached', async (context) => { @@ -210,50 +112,45 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined - const outputs = - previewOutput == null - ? [{ output: videoOutput, mirrorMode: 'auto' as const }] - : [ - { output: previewOutput, mirrorMode: 'auto' as const }, - { output: videoOutput, mirrorMode: 'auto' as const }, - ] - const constraints: Constraint[] = - previewOutput == null - ? [{ fps: 60 }, { resolutionBias: videoOutput }] - : [ - { fps: 60 }, - { resolutionBias: previewOutput }, - { resolutionBias: videoOutput }, - ] let selectedConfig: CameraSessionConfig | undefined - try { - await session.configure([ - { - input: backDevice, - outputs, - constraints, - onSessionConfigSelected: (config) => { - selectedConfig = config - }, - }, - ]) - await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) - - return await withStartedSession( - session, - async (throwIfSessionError) => { - await waitForOutputsToAttach(outputs, throwIfSessionError) - const currentResolution = videoOutput.currentResolution - if (selectedConfig == null) throw new Error('no selected config') - if (currentResolution == null) - throw new Error('no video resolution') - return { - selectedFPS: selectedConfig.selectedFPS, - resolution: currentResolution, - } + await session.configure([ + { + input: backDevice, + outputs: + previewOutput == null + ? [{ output: videoOutput, mirrorMode: 'auto' }] + : [ + { output: previewOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + ], + constraints: + previewOutput == null + ? [{ fps: 60 }, { resolutionBias: videoOutput }] + : [ + { fps: 60 }, + { resolutionBias: previewOutput }, + { resolutionBias: videoOutput }, + ], + onSessionConfigSelected: (config) => { + selectedConfig = config }, - ) + }, + ]) + await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) + + await session.start() + try { + await waitUntil(() => videoOutput.currentResolution != null, { + timeout: 10_000, + }) + const currentResolution = videoOutput.currentResolution + if (selectedConfig == null) throw new Error('no selected config') + if (currentResolution == null) throw new Error('no video resolution') + return { + selectedFPS: selectedConfig.selectedFPS, + resolution: currentResolution, + } } finally { await session.stop() } @@ -339,37 +236,35 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined - const outputs = - previewOutput == null - ? [{ output: videoOutput, mirrorMode: 'auto' as const }] - : [ - { output: previewOutput, mirrorMode: 'auto' as const }, - { output: videoOutput, mirrorMode: 'auto' as const }, - ] - const constraints: Constraint[] = - previewOutput == null - ? [{ resolutionBias: videoOutput }] - : [{ resolutionBias: previewOutput }, { resolutionBias: videoOutput }] + await session.configure([ + { + input: backDevice, + outputs: + previewOutput == null + ? [{ output: videoOutput, mirrorMode: 'auto' }] + : [ + { output: previewOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + ], + constraints: + previewOutput == null + ? [{ resolutionBias: videoOutput }] + : [ + { resolutionBias: previewOutput }, + { resolutionBias: videoOutput }, + ], + }, + ]) + + await session.start() try { - await session.configure([ - { - input: backDevice, - outputs, - constraints, - }, - ]) - - return await withStartedSession( - session, - async (throwIfSessionError) => { - await waitForOutputsToAttach(outputs, throwIfSessionError) - const currentResolution = videoOutput.currentResolution - if (currentResolution == null) - throw new Error('no video resolution') - return currentResolution - }, - ) + await waitUntil(() => videoOutput.currentResolution != null, { + timeout: 10_000, + }) + const currentResolution = videoOutput.currentResolution + if (currentResolution == null) throw new Error('no video resolution') + return currentResolution } finally { await session.stop() } @@ -426,54 +321,49 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined - const outputs = - previewOutput == null - ? [{ output: videoOutput, mirrorMode: 'auto' as const }] - : [ - { output: previewOutput, mirrorMode: 'auto' as const }, - { output: videoOutput, mirrorMode: 'auto' as const }, - ] - const constraints: Constraint[] = - previewOutput == null - ? [{ fps: 30 }, { resolutionBias: videoOutput }] - : [ - { fps: 30 }, - { resolutionBias: videoOutput }, - { resolutionBias: previewOutput }, - { resolutionBias: videoOutput }, - ] let selectedConfig: CameraSessionConfig | undefined // The same constraints list `useCameraController` builds: the user's // explicit constraints first, then one auto-appended resolutionBias // per output (preview is listed as the first output by ). - try { - await session.configure([ - { - input: backDevice, - outputs, - constraints, - onSessionConfigSelected: (config) => { - selectedConfig = config - }, - }, - ]) - await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) - - return await withStartedSession( - session, - async (throwIfSessionError) => { - await waitForOutputsToAttach(outputs, throwIfSessionError) - const currentResolution = videoOutput.currentResolution - if (selectedConfig == null) throw new Error('no selected config') - if (currentResolution == null) - throw new Error('no video resolution') - return { - selectedFPS: selectedConfig.selectedFPS, - resolution: currentResolution, - } + await session.configure([ + { + input: backDevice, + outputs: + previewOutput == null + ? [{ output: videoOutput, mirrorMode: 'auto' }] + : [ + { output: previewOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + ], + constraints: + previewOutput == null + ? [{ fps: 30 }, { resolutionBias: videoOutput }] + : [ + { fps: 30 }, + { resolutionBias: videoOutput }, + { resolutionBias: previewOutput }, + { resolutionBias: videoOutput }, + ], + onSessionConfigSelected: (config) => { + selectedConfig = config }, - ) + }, + ]) + await waitUntil(() => selectedConfig != null, { timeout: 5_000 }) + + await session.start() + try { + await waitUntil(() => videoOutput.currentResolution != null, { + timeout: 10_000, + }) + const currentResolution = videoOutput.currentResolution + if (selectedConfig == null) throw new Error('no selected config') + if (currentResolution == null) throw new Error('no video resolution') + return { + selectedFPS: selectedConfig.selectedFPS, + resolution: currentResolution, + } } finally { await session.stop() } @@ -516,37 +406,35 @@ describe('VisionCamera - Constraints', () => { const previewOutput = includePreview ? VisionCamera.createPreviewOutput() : undefined - const outputs = - previewOutput == null - ? [{ output: photoOutput, mirrorMode: 'auto' as const }] - : [ - { output: previewOutput, mirrorMode: 'auto' as const }, - { output: photoOutput, mirrorMode: 'auto' as const }, - ] - const constraints: Constraint[] = - previewOutput == null - ? [{ resolutionBias: photoOutput }] - : [{ resolutionBias: photoOutput }, { resolutionBias: previewOutput }] + await session.configure([ + { + input: backDevice, + outputs: + previewOutput == null + ? [{ output: photoOutput, mirrorMode: 'auto' }] + : [ + { output: previewOutput, mirrorMode: 'auto' }, + { output: photoOutput, mirrorMode: 'auto' }, + ], + constraints: + previewOutput == null + ? [{ resolutionBias: photoOutput }] + : [ + { resolutionBias: photoOutput }, + { resolutionBias: previewOutput }, + ], + }, + ]) + + await session.start() try { - await session.configure([ - { - input: backDevice, - outputs, - constraints, - }, - ]) - - return await withStartedSession( - session, - async (throwIfSessionError) => { - await waitForOutputsToAttach(outputs, throwIfSessionError) - const currentResolution = photoOutput.currentResolution - if (currentResolution == null) - throw new Error('no photo resolution') - return currentResolution - }, - ) + await waitUntil(() => photoOutput.currentResolution != null, { + timeout: 10_000, + }) + const currentResolution = photoOutput.currentResolution + if (currentResolution == null) throw new Error('no photo resolution') + return currentResolution } finally { await session.stop() } @@ -561,7 +449,7 @@ describe('VisionCamera - Constraints', () => { expect(withPreviewEdges.long).toBe(photoOnlyEdges.long) }) - it('configures and starts photoHDR: true when supported', async (context) => { + it('resolves photoHDR: true when the device supports photo HDR', async (context) => { if (!backDevice.supportsPhotoHDR) { return context.skip('photoHDR: not supported on this device') } @@ -571,31 +459,15 @@ describe('VisionCamera - Constraints', () => { quality: 0.8, qualityPrioritization: 'balanced', }) - const outputs = [{ output: photoOutput, mirrorMode: 'auto' as const }] - const constraints: Constraint[] = [ - { resolutionBias: photoOutput }, - { photoHDR: true }, - ] - const resolvedConfig = await VisionCamera.resolveConstraints( - backDevice, - outputs, - constraints, - ) - if (!resolvedConfig.isPhotoHDREnabled) { - return context.skip('photoHDR: exact output graph resolves without HDR') - } - await withRunningConstraintSession( + const config = await VisionCamera.resolveConstraints( backDevice, - outputs, - constraints, - (config) => { - expect(config.isPhotoHDREnabled).toBe(true) - }, - resolvedConfig, + [{ output: photoOutput, mirrorMode: 'auto' }], + [{ resolutionBias: photoOutput }, { photoHDR: true }], ) + expect(config.isPhotoHDREnabled).toBe(true) }) - it('configures and starts an HDR video dynamic range when supported', async (context) => { + it('resolves a HDR video dynamic range when the device supports it', async (context) => { const hasHdr = backDevice.supportedVideoDynamicRanges.some( (d) => d.bitDepth === 'hdr-10-bit', ) @@ -606,182 +478,175 @@ describe('VisionCamera - Constraints', () => { targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) - const outputs = [{ output: videoOutput, mirrorMode: 'auto' as const }] - const constraints: Constraint[] = [ - { videoDynamicRange: CommonDynamicRanges.ANY_HDR }, - { resolutionBias: videoOutput }, - ] - const resolvedConfig = await VisionCamera.resolveConstraints( - backDevice, - outputs, - constraints, - ) - if (resolvedConfig.selectedVideoDynamicRange?.bitDepth !== 'hdr-10-bit') { - return context.skip( - 'video HDR: exact output graph resolves without an HDR dynamic range', - ) - } - await withRunningConstraintSession( + const config = await VisionCamera.resolveConstraints( backDevice, - outputs, - constraints, - (config) => { - expect(config.selectedVideoDynamicRange?.bitDepth).toBe('hdr-10-bit') - }, - resolvedConfig, + [{ output: videoOutput, mirrorMode: 'auto' }], + [ + { videoDynamicRange: CommonDynamicRanges.ANY_HDR }, + { resolutionBias: videoOutput }, + ], ) + expect(config.selectedVideoDynamicRange?.bitDepth).toBe('hdr-10-bit') }) - it('configures and starts a video stabilization constraint when supported', async (context) => { - const modes = [ - 'cinematic-extended-enhanced', - 'cinematic-extended', - 'cinematic', - 'standard', - 'low-latency', - ] as const - let selection: - | { - device: CameraDevice - mode: (typeof modes)[number] - } - | undefined - for (const mode of modes) { - const device = factory.cameraDevices.find((candidate) => - candidate.supportsVideoStabilizationMode(mode), - ) - if (device != null) { - selection = { device, mode } - break - } - } - if (selection == null) { + it('configures and starts cinematic video stabilization when supported', async (context) => { + const stabDevice = factory.cameraDevices.find((d) => + d.supportsVideoStabilizationMode('cinematic'), + ) + if (stabDevice == null) { return context.skip( - 'videoStabilizationMode: no device supports an explicit mode', + 'videoStabilizationMode: no device on this system supports "cinematic"', ) } - const { device, mode } = selection const videoOutput = VisionCamera.createVideoOutput({ targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) - const outputs = [{ output: videoOutput, mirrorMode: 'auto' as const }] - const constraints: Constraint[] = [{ videoStabilizationMode: mode }] - const resolvedConfig = await VisionCamera.resolveConstraints( - device, - outputs, - constraints, - ) - if (resolvedConfig.selectedVideoStabilizationMode !== mode) { - return context.skip( - `videoStabilizationMode: graph resolves ${mode} to ${resolvedConfig.selectedVideoStabilizationMode ?? 'default'}`, + const session = await VisionCamera.createCameraSession(false) + let selectedConfig: CameraSessionConfig | undefined + let didStart = false + let sessionError: Error | undefined + const startSub = session.addOnStartedListener(() => { + didStart = true + }) + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + try { + await session.configure([ + { + input: stabDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ videoStabilizationMode: 'cinematic' }], + onSessionConfigSelected: (config) => { + selectedConfig = config + }, + }, + ]) + await session.start() + await waitUntil( + () => { + if (sessionError != null) throw sessionError + return ( + selectedConfig != null && + didStart && + videoOutput.currentResolution != null + ) + }, + { timeout: 10_000 }, ) + + expect(selectedConfig?.selectedVideoStabilizationMode).toBe('cinematic') + } finally { + startSub.remove() + errorSub.remove() + await session.stop() } - await withRunningConstraintSession( - device, - outputs, - constraints, - (config) => { - expect(config.selectedVideoStabilizationMode).toBe(mode) - }, - resolvedConfig, - ) }) - it('configures and starts a preview stabilization constraint when supported', async (context) => { - const modes = [ - 'preview-optimized', - 'cinematic-extended-enhanced', - 'cinematic-extended', - 'cinematic', - 'standard', - 'low-latency', - ] as const - let selection: - | { - device: CameraDevice - mode: (typeof modes)[number] - } - | undefined - for (const mode of modes) { - const device = factory.cameraDevices.find((candidate) => - candidate.supportsPreviewStabilizationMode(mode), - ) - if (device != null) { - selection = { device, mode } - break - } - } - if (selection == null) { + it('resolves a preview stabilization constraint when supported', async (context) => { + const stabDevice = factory.cameraDevices.find((d) => + d.supportsPreviewStabilizationMode('preview-optimized'), + ) + if (stabDevice == null) { return context.skip( - 'previewStabilizationMode: no device supports an explicit mode', + 'previewStabilizationMode: no device on this system supports "preview-optimized"', ) } - const { device, mode } = selection const previewOutput = VisionCamera.createPreviewOutput() + const config = await VisionCamera.resolveConstraints( + stabDevice, + [{ output: previewOutput, mirrorMode: 'auto' }], + [{ previewStabilizationMode: 'preview-optimized' }], + ) + expect(config.selectedPreviewStabilizationMode).toBe('preview-optimized') + }) + + it('resolves a binned: true constraint when supported', async (context) => { const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, containerFormat: 'jpeg', quality: 0.8, qualityPrioritization: 'balanced', }) - const outputs = [ - { output: previewOutput, mirrorMode: 'auto' as const }, - { output: photoOutput, mirrorMode: 'auto' as const }, - ] - const constraints: Constraint[] = [ - { previewStabilizationMode: mode }, - { resolutionBias: photoOutput }, - ] - const resolvedConfig = await VisionCamera.resolveConstraints( - device, - outputs, - constraints, + const config = await VisionCamera.resolveConstraints( + backDevice, + [{ output: photoOutput, mirrorMode: 'auto' }], + [{ binned: true }], ) - if (resolvedConfig.selectedPreviewStabilizationMode !== mode) { + if (config.isBinned !== true) { return context.skip( - `previewStabilizationMode: graph resolves ${mode} to ${resolvedConfig.selectedPreviewStabilizationMode ?? 'default'}`, + `binned: true: device resolved to isBinned=${config.isBinned}`, ) } - await withRunningConstraintSession( - device, - outputs, - constraints, - (config) => { - expect(config.selectedPreviewStabilizationMode).toBe(mode) - }, - resolvedConfig, - ) + expect(config.isBinned).toBe(true) }) - it('configures and starts binned: true when supported', async (context) => { + it('resolves the same config via VisionCamera.resolveConstraints and session.configure', async () => { const photoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, containerFormat: 'jpeg', quality: 0.8, qualityPrioritization: 'balanced', }) - const outputs = [{ output: photoOutput, mirrorMode: 'auto' as const }] - const constraints: Constraint[] = [{ binned: true }] - const resolvedConfig = await VisionCamera.resolveConstraints( + const outputConfig = { + output: photoOutput, + mirrorMode: 'auto' as const, + } + const constraints: Constraint[] = [{ fps: 30 }] + + const standalone = await VisionCamera.resolveConstraints( backDevice, - outputs, + [outputConfig], constraints, ) - if (resolvedConfig.isBinned !== true) { - return context.skip( - `binned: true: device resolved to isBinned=${resolvedConfig.isBinned}`, + + const session = await VisionCamera.createCameraSession(false) + let sessionConfig: CameraSessionConfig | undefined + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + try { + await session.configure([ + { + input: backDevice, + outputs: [outputConfig], + constraints, + onSessionConfigSelected: (config) => { + sessionConfig = config + }, + }, + ]) + await waitUntil( + () => { + if (sessionError != null) throw sessionError + return sessionConfig != null + }, + { timeout: 5_000 }, ) + if (sessionConfig == null) throw new Error('no selected config') + + expect(sessionConfig.selectedFPS).toBe(standalone.selectedFPS) + expect(sessionConfig.selectedVideoStabilizationMode).toBe( + standalone.selectedVideoStabilizationMode, + ) + expect(sessionConfig.selectedPreviewStabilizationMode).toBe( + standalone.selectedPreviewStabilizationMode, + ) + expect(sessionConfig.selectedVideoDynamicRange).toEqual( + standalone.selectedVideoDynamicRange, + ) + expect(sessionConfig.isPhotoHDREnabled).toBe(standalone.isPhotoHDREnabled) + expect(sessionConfig.nativePixelFormat).toBe(standalone.nativePixelFormat) + expect(sessionConfig.autoFocusSystem).toBe(standalone.autoFocusSystem) + expect(sessionConfig.isBinned).toBe(standalone.isBinned) + } finally { + errorSub.remove() + await session.stop() } - await withRunningConstraintSession( - backDevice, - outputs, - constraints, - (config) => { - expect(config.isBinned).toBe(true) - }, - resolvedConfig, - ) }) // Verifies the resolver's priority mechanism by running the same pair of @@ -793,8 +658,7 @@ describe('VisionCamera - Constraints', () => { // This catches regressions like "resolver always drops the first constraint // instead of the last" or "priority order is silently reversed", without // depending on which feature combinations the AWS Device Farm device happens - // to support together. This stays resolver-only because the individual HDR - // and stabilization constraints above already cross-check a running session. + // to support together. it('honors constraint priority ordering between stabilization and HDR', async (context) => { let chosenStabilizationMode: 'cinematic' | 'standard' | undefined for (const mode of ['cinematic', 'standard'] as const) { @@ -846,85 +710,76 @@ describe('VisionCamera - Constraints', () => { expect(hdrFirst.selectedVideoDynamicRange?.bitDepth).toBe('hdr-10-bit') }) - it('reconfigures the running session with a different constraint set', async (context) => { - const videoOutput = VisionCamera.createVideoOutput({ - targetResolution: CommonResolutions.HD_16_9, - enableAudio: false, - }) - const outputs = [{ output: videoOutput, mirrorMode: 'auto' as const }] - const firstConstraints: Constraint[] = [{ fps: 30 }] - const secondConstraints: Constraint[] = [{ fps: 60 }] - const firstResolvedConfig = await VisionCamera.resolveConstraints( - backDevice, - outputs, - firstConstraints, - ) - const secondResolvedConfig = await VisionCamera.resolveConstraints( - backDevice, - outputs, - secondConstraints, - ) - if ( - firstResolvedConfig.selectedFPS !== 30 || - secondResolvedConfig.selectedFPS !== 60 - ) { + it('selects 60 fps while reconfiguring a running session', async (context) => { + if (!backDevice.supportsFPS(60)) { return context.skip( - `graph resolves 30→60 fps to ${firstResolvedConfig.selectedFPS ?? 'default'}→${secondResolvedConfig.selectedFPS ?? 'default'} fps`, + 'reconfigure with new constraints: fps: 60 not supported', ) } const session = await VisionCamera.createCameraSession(false) - let firstConfig: CameraSessionConfig | undefined + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + + let didStart = false + let sessionError: Error | undefined + const startSub = session.addOnStartedListener(() => { + didStart = true + }) + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + try { + let firstConfig: CameraSessionConfig | undefined await session.configure([ { input: backDevice, - outputs, - constraints: firstConstraints, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ fps: 30 }], onSessionConfigSelected: (config) => { firstConfig = config }, }, ]) - await waitUntil(() => firstConfig != null, { timeout: 5_000 }) - if (firstConfig == null) throw new Error('no first selected config') - expectConfigsToMatch(firstConfig, firstResolvedConfig) - expect(firstConfig.selectedFPS).toBe(30) - - await withStartedSession(session, async (throwIfSessionError) => { - await waitForOutputsToAttach(outputs, throwIfSessionError) - - let secondConfig: CameraSessionConfig | undefined - await session.configure([ - { - input: backDevice, - outputs, - constraints: secondConstraints, - onSessionConfigSelected: (config) => { - secondConfig = config - }, - }, - ]) - await waitUntil( - () => { - throwIfSessionError() - return secondConfig != null - }, - { timeout: 5_000 }, - ) - if (secondConfig == null) throw new Error('no second selected config') - expectConfigsToMatch(secondConfig, secondResolvedConfig) - expect(secondConfig.selectedFPS).toBe(60) - - await waitUntil( - () => { - throwIfSessionError() - return session.isRunning + await session.start() + await waitUntil( + () => { + if (sessionError != null) throw sessionError + return ( + firstConfig != null && + didStart && + videoOutput.currentResolution != null + ) + }, + { timeout: 10_000 }, + ) + expect(firstConfig?.selectedFPS).toBe(30) + + let secondConfig: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ fps: 60 }], + onSessionConfigSelected: (config) => { + secondConfig = config }, - { timeout: 10_000 }, - ) - }) + }, + ]) + await waitUntil( + () => { + if (sessionError != null) throw sessionError + return secondConfig != null + }, + { timeout: 5_000 }, + ) + expect(secondConfig?.selectedFPS).toBe(60) } finally { + startSub.remove() + errorSub.remove() await session.stop() } }) diff --git a/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts b/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts index 48fd6b7be1..a47b724ccd 100644 --- a/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts @@ -225,7 +225,7 @@ describe('VisionCamera - Multi-Output', () => { } }) - it('sequentially replaces photo, video, and frame outputs without disrupting untouched outputs', async () => { + it('replaces the photo output while a video + frame output are also attached', async () => { const session = await VisionCamera.createCameraSession(false) const firstPhotoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.HD_4_3, @@ -233,13 +233,13 @@ describe('VisionCamera - Multi-Output', () => { quality: 0.8, qualityPrioritization: 'balanced', }) - const firstVideoOutput = VisionCamera.createVideoOutput({ + const videoOutput = VisionCamera.createVideoOutput({ targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) - const yuvFrameOutput = VisionCamera.createFrameOutput({ + const frameOutput = VisionCamera.createFrameOutput({ targetResolution: CommonResolutions.HD_16_9, - pixelFormat: 'yuv', + pixelFormat: 'native', enablePreviewSizedOutputBuffers: false, enablePhysicalBufferRotation: false, enableCameraMatrixDelivery: false, @@ -247,56 +247,35 @@ describe('VisionCamera - Multi-Output', () => { dropFramesWhileBusy: true, }) - let yuvFramesReceived = 0 - let yuvIsPlanar: boolean | undefined + let framesReceived = 0 let sessionError: Error | undefined - const reportYuvFrame = (isPlanar: boolean) => { - yuvFramesReceived++ - yuvIsPlanar = isPlanar + const onFrameReceived = () => { + framesReceived++ } const errorSub = session.addOnErrorListener((error) => { sessionError = error }) - await session.configure([ - { - input: backDevice, - outputs: [ - { output: firstPhotoOutput, mirrorMode: 'auto' }, - { output: firstVideoOutput, mirrorMode: 'auto' }, - { output: yuvFrameOutput, mirrorMode: 'auto' }, - ], - constraints: [], - }, - ]) - - const yuvRuntime = workletsProvider.createRuntimeForThread( - yuvFrameOutput.thread, - ) - yuvRuntime.setOnFrameCallback(yuvFrameOutput, (frame) => { + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { 'worklet' - scheduleOnRN(reportYuvFrame, frame.isPlanar) + scheduleOnRN(onFrameReceived) frame.dispose() }) - await session.start() - try { - await waitUntil(() => yuvIsPlanar != null || sessionError != null, { - timeout: 15_000, - }) - expect(sessionError).toBe(undefined) - expect(yuvIsPlanar).toBe(true) - - const waitForMoreYuvFrames = async () => { - const framesBefore = yuvFramesReceived - await waitUntil( - () => yuvFramesReceived > framesBefore + 2 || sessionError != null, - { timeout: 15_000 }, - ) - expect(sessionError).toBe(undefined) - expect(yuvFramesReceived).toBeGreaterThan(framesBefore) - } + await session.configure([ + { + input: backDevice, + outputs: [ + { output: firstPhotoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + await session.start() const secondPhotoOutput = VisionCamera.createPhotoOutput({ targetResolution: CommonResolutions.FHD_4_3, @@ -304,80 +283,211 @@ describe('VisionCamera - Multi-Output', () => { quality: 0.5, qualityPrioritization: 'quality', }) + await session.configure([ { input: backDevice, outputs: [ { output: secondPhotoOutput, mirrorMode: 'auto' }, - { output: firstVideoOutput, mirrorMode: 'auto' }, - { output: yuvFrameOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, ], constraints: [], }, ]) - const photoAfterPhotoReplacement = await secondPhotoOutput.capturePhoto( + // The replacement photo output captures. + const photo = await secondPhotoOutput.capturePhoto( { flashMode: 'off', enableShutterSound: false }, {}, ) - expect(photoAfterPhotoReplacement.width).toBeGreaterThan(0) - expect(photoAfterPhotoReplacement.height).toBeGreaterThan(0) - photoAfterPhotoReplacement.dispose() - - const firstRecorder = await firstVideoOutput.createRecorder({}) - const firstRecordingFinished = deferred() - await firstRecorder.startRecording( - () => firstRecordingFinished.resolve(), - firstRecordingFinished.reject, - ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + + // The untouched video output still records. + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + await recorder.startRecording(() => finished.resolve(), finished.reject) await sleep(500) - await firstRecorder.stopRecording() - await withTimeout( - firstRecordingFinished.promise, - 15_000, - 'recording after photo replacement', + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + + // The untouched native frame output still streams. + const framesAtCheck = framesReceived + await waitUntil( + () => framesReceived > framesAtCheck + 2 || sessionError != null, + { timeout: 15_000 }, ) - await waitForMoreYuvFrames() + expect(sessionError).toBe(undefined) + expect(framesReceived).toBeGreaterThan(framesAtCheck) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it('replaces the video output while a photo + frame output are also attached', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const firstVideoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + let framesReceived = 0 + let sessionError: Error | undefined + const onFrameReceived = () => { + framesReceived++ + } + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(onFrameReceived) + frame.dispose() + }) + + try { + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: firstVideoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + await session.start() const secondVideoOutput = VisionCamera.createVideoOutput({ targetResolution: CommonResolutions.FHD_16_9, enableAudio: false, }) + await session.configure([ { input: backDevice, outputs: [ - { output: secondPhotoOutput, mirrorMode: 'auto' }, + { output: photoOutput, mirrorMode: 'auto' }, { output: secondVideoOutput, mirrorMode: 'auto' }, - { output: yuvFrameOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, ], constraints: [], }, ]) - const photoAfterVideoReplacement = await secondPhotoOutput.capturePhoto( + // The replacement video output records. + const recorder = await secondVideoOutput.createRecorder({}) + const finished = deferred() + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(500) + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + + // The untouched photo output still captures. + const photo = await photoOutput.capturePhoto( { flashMode: 'off', enableShutterSound: false }, {}, ) - expect(photoAfterVideoReplacement.width).toBeGreaterThan(0) - expect(photoAfterVideoReplacement.height).toBeGreaterThan(0) - photoAfterVideoReplacement.dispose() - - const recorderAfterVideoReplacement = - await secondVideoOutput.createRecorder({}) - const recordingAfterVideoReplacement = deferred() - await recorderAfterVideoReplacement.startRecording( - () => recordingAfterVideoReplacement.resolve(), - recordingAfterVideoReplacement.reject, - ) - await sleep(500) - await recorderAfterVideoReplacement.stopRecording() - await withTimeout( - recordingAfterVideoReplacement.promise, - 15_000, - 'recording after video replacement', + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + + // The untouched native frame output still streams. + const framesAtCheck = framesReceived + await waitUntil( + () => framesReceived > framesAtCheck + 2 || sessionError != null, + { timeout: 15_000 }, ) - await waitForMoreYuvFrames() + expect(sessionError).toBe(undefined) + expect(framesReceived).toBeGreaterThan(framesAtCheck) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it('replaces the frame output with a different pixel format while a photo + video output are also attached', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const yuvFrameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'yuv', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + let yuvIsPlanar: boolean | undefined + const reportYuv = (planar: boolean) => { + yuvIsPlanar = planar + } + const yuvRuntime = workletsProvider.createRuntimeForThread( + yuvFrameOutput.thread, + ) + yuvRuntime.setOnFrameCallback(yuvFrameOutput, (frame) => { + 'worklet' + scheduleOnRN(reportYuv, frame.isPlanar) + frame.dispose() + }) + + try { + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: yuvFrameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + await session.start() + + await waitUntil(() => yuvIsPlanar != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + expect(yuvIsPlanar).toBe(true) yuvRuntime.setOnFrameCallback(yuvFrameOutput, undefined) @@ -390,12 +500,13 @@ describe('VisionCamera - Multi-Output', () => { allowDeferredStart: false, dropFramesWhileBusy: true, }) + await session.configure([ { input: backDevice, outputs: [ - { output: secondPhotoOutput, mirrorMode: 'auto' }, - { output: secondVideoOutput, mirrorMode: 'auto' }, + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, { output: rgbFrameOutput, mirrorMode: 'auto' }, ], constraints: [], @@ -403,8 +514,8 @@ describe('VisionCamera - Multi-Output', () => { ]) let rgbIsPlanar: boolean | undefined - const reportRgb = (isPlanar: boolean) => { - rgbIsPlanar = isPlanar + const reportRgb = (planar: boolean) => { + rgbIsPlanar = planar } const rgbRuntime = workletsProvider.createRuntimeForThread( rgbFrameOutput.thread, @@ -421,27 +532,22 @@ describe('VisionCamera - Multi-Output', () => { expect(sessionError).toBe(undefined) expect(rgbIsPlanar).toBe(false) - const photoAfterFrameReplacement = await secondPhotoOutput.capturePhoto( + // The untouched photo output still captures. + const photo = await photoOutput.capturePhoto( { flashMode: 'off', enableShutterSound: false }, {}, ) - expect(photoAfterFrameReplacement.width).toBeGreaterThan(0) - expect(photoAfterFrameReplacement.height).toBeGreaterThan(0) - photoAfterFrameReplacement.dispose() - - const secondRecorder = await secondVideoOutput.createRecorder({}) - const secondRecordingFinished = deferred() - await secondRecorder.startRecording( - () => secondRecordingFinished.resolve(), - secondRecordingFinished.reject, - ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + + // The untouched video output still records. + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + await recorder.startRecording(() => finished.resolve(), finished.reject) await sleep(500) - await secondRecorder.stopRecording() - await withTimeout( - secondRecordingFinished.promise, - 15_000, - 'recording after frame replacement', - ) + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') expect(sessionError).toBe(undefined) } finally { rgbRuntime.setOnFrameCallback(rgbFrameOutput, undefined) diff --git a/apps/simple-camera/__tests__/visioncamera.video.harness.ts b/apps/simple-camera/__tests__/visioncamera.video.harness.ts index 5e37c792f8..33aeb1d0cf 100644 --- a/apps/simple-camera/__tests__/visioncamera.video.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.video.harness.ts @@ -659,20 +659,21 @@ describe('VisionCamera - Video', () => { targetResolution: CommonResolutions.HD_16_9, enableAudio: false, }) - await session.configure([ - { - input: backDevice, - outputs: [{ output: videoOutput, mirrorMode: 'auto' }], - constraints: [], - }, - ]) try { - await videoOutput.setOutputSettings({}) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) const codecs = videoOutput.getSupportedVideoCodecs() expect(codecs.length).toBeGreaterThan(0) expect(codecs).not.toContain('unknown') + + await videoOutput.setOutputSettings({}) for (const codec of codecs) { await videoOutput.setOutputSettings({ codec }) }