diff --git a/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts index ddb6a82684..1eca566d36 100644 --- a/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts @@ -1,3 +1,4 @@ +import { Platform } from 'react-native' import { beforeAll, describe, @@ -109,6 +110,103 @@ describe('VisionCamera - Constraints', () => { await session.stop() }) + it('keeps front TrueDepth FHD resolution bias when requesting 60 fps', async (context) => { + if (Platform.OS !== 'ios') { + return context.skip('front TrueDepth FHD@60 resolution bias: iOS only') + } + + const device = factory.cameraDevices.find( + (d) => d.position === 'front' && d.type === 'true-depth', + ) + if (device == null) { + return context.skip( + 'front TrueDepth FHD@60 resolution bias: no front TrueDepth camera', + ) + } + if (!device.supportsFPS(60)) { + return context.skip( + 'front TrueDepth FHD@60 resolution bias: 60 fps not supported', + ) + } + + const supportedVideoResolutions = device.getSupportedResolutions('video') + const hasResolution = (target: { width: number; height: number }) => { + const targetShortEdge = Math.min(target.width, target.height) + const targetLongEdge = Math.max(target.width, target.height) + return supportedVideoResolutions.some((resolution) => { + const shortEdge = Math.min(resolution.width, resolution.height) + const longEdge = Math.max(resolution.width, resolution.height) + return shortEdge === targetShortEdge && longEdge === targetLongEdge + }) + } + const hasFHD = hasResolution(CommonResolutions.FHD_16_9) + const hasUHD = hasResolution(CommonResolutions.UHD_16_9) + if (!hasFHD || !hasUHD) { + return context.skip( + `front TrueDepth FHD@60 resolution bias: video resolutions do not include FHD and UHD (${supportedVideoResolutions + .map((r) => `${r.width}x${r.height}`) + .join(', ')})`, + ) + } + + await VisionCamera.requestMicrophonePermission() + if (VisionCamera.microphonePermissionStatus !== 'authorized') { + return context.skip( + 'front TrueDepth FHD@60 resolution bias: microphone permission not authorized', + ) + } + + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.FHD_16_9, + enableAudio: true, + }) + + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: device, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: videoOutput }, { fps: 60 }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + expect(received?.selectedFPS).toBe(60) + + await session.start() + try { + await waitUntil(() => videoOutput.currentResolution != null, { + timeout: 10_000, + }) + + const reported = videoOutput.currentResolution + expect(reported).toBeDefined() + if (reported == null) throw new Error('no reported video resolution') + + const requestedShortEdge = Math.min( + CommonResolutions.FHD_16_9.width, + CommonResolutions.FHD_16_9.height, + ) + const requestedLongEdge = Math.max( + CommonResolutions.FHD_16_9.width, + CommonResolutions.FHD_16_9.height, + ) + const reportedShortEdge = Math.min(reported.width, reported.height) + const reportedLongEdge = Math.max(reported.width, reported.height) + console.log( + `front TrueDepth FHD@60 target=${CommonResolutions.FHD_16_9.width}x${CommonResolutions.FHD_16_9.height} ` + + `resolved=${reported.width}x${reported.height} config=${received?.toString()}`, + ) + expect(reportedShortEdge).toBe(requestedShortEdge) + expect(reportedLongEdge).toBe(requestedLongEdge) + } finally { + await session.stop() + } + }) + it('resolves photoHDR: true when the device supports photo HDR', async (context) => { if (!backDevice.supportsPhotoHDR) { return context.skip('photoHDR: not supported on this device') diff --git a/packages/react-native-vision-camera/ios/Hybrid Objects/Constraints/ConstraintResolver.swift b/packages/react-native-vision-camera/ios/Hybrid Objects/Constraints/ConstraintResolver.swift index 92f2bec4e3..be38d40a2c 100644 --- a/packages/react-native-vision-camera/ios/Hybrid Objects/Constraints/ConstraintResolver.swift +++ b/packages/react-native-vision-camera/ios/Hybrid Objects/Constraints/ConstraintResolver.swift @@ -61,14 +61,18 @@ enum ConstraintResolver { // Hard requirements: filter out formats that are not possible at all. let formats = try filterFormats(device.formats, outputs: outputs, isMultiCam: isMultiCam) - // Evaluate all formats and pick the one with the lowest total penalty. + // Evaluate all formats and pick the one with the best penalty vector. + // Constraints are ordered by priority, so we compare penalties lexicographically + // instead of summing heterogeneous units (fps deltas, pixel distances, + // fallback steps, ...). This makes a higher-priority constraint impossible + // to swamp numerically with a lower-priority one. // This is a single pass - format selection and enabled constraints // are computed together from the same resolve() calls. guard let best = try formats .map({ try evaluate($0, constraints: constraints) }) - .min(by: { $0.totalPenalty < $1.totalPenalty }) + .min(by: { $0.isBetterMatch(than: $1) }) else { throw RuntimeError.error(withMessage: "The given `device` does not have any `formats`!") } @@ -88,13 +92,13 @@ enum ConstraintResolver { // MARK: - Format Evaluation /// Evaluates all constraints against a single format in one pass. - /// Returns the format, its total weighted penalty, and the enabled constraints + /// Returns the format, its ordered penalties, and the enabled constraints /// that would apply if this format is selected. private static func evaluate( _ format: AVCaptureDevice.Format, constraints: [InternalConstraint] ) throws(RuntimeError) -> FormatEvaluation { - var totalPenalty = 0.0 + var penalties: [ConstraintPenalty] = [] // First-match-wins accumulators (priority order = array order) var fps: Double? @@ -102,10 +106,9 @@ enum ConstraintResolver { var previewStabilizationMode: TargetStabilizationMode? var videoDynamicRange: TargetDynamicRange? - for (index, constraint) in constraints.enumerated() { - let weight = Double(constraints.count - index) + for constraint in constraints { let evaluation = try constraint.evaluate(for: format) - totalPenalty += evaluation.penalty.distance * weight + penalties.append(evaluation.penalty) // Accumulate enabled values - first of each type wins (= highest priority) switch evaluation.resolved { @@ -130,7 +133,7 @@ enum ConstraintResolver { return FormatEvaluation( format: format, - totalPenalty: totalPenalty, + penalties: penalties, enabledConstraints: enabledConstraints) } @@ -205,8 +208,16 @@ struct ConstraintEvaluation { /// Result of evaluating all constraints against a single format. struct FormatEvaluation { let format: AVCaptureDevice.Format - let totalPenalty: Double + let penalties: [ConstraintPenalty] let enabledConstraints: EnabledConstraints + + func isBetterMatch(than other: FormatEvaluation) -> Bool { + for (left, right) in zip(penalties, other.penalties) { + if left == right { continue } + return left < right + } + return false + } } // MARK: - Constraint / InternalConstraint Dispatchers @@ -229,7 +240,7 @@ extension Constraint { case .fifth(let dynamicRange): let r = dynamicRange.resolve(for: format) return ConstraintEvaluation(penalty: r.penalty, resolved: .videoDynamicRange(r.resolvedValue)) - case .sixth( /* photoHDR */_): + case .sixth(_): // Photo HDR is not supported on iOS. return ConstraintEvaluation(penalty: .noPenalty, resolved: .formatOnly) case .seventh(let pixelFormat):