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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/simple-camera/__tests__/visioncamera.constraints.harness.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Platform } from 'react-native'
import {
beforeAll,
describe,
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`!")
}
Expand All @@ -88,24 +92,23 @@ 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?
var videoStabilizationMode: TargetStabilizationMode?
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 {
Expand All @@ -130,7 +133,7 @@ enum ConstraintResolver {

return FormatEvaluation(
format: format,
totalPenalty: totalPenalty,
penalties: penalties,
enabledConstraints: enabledConstraints)
}

Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
Loading