ondelva

← Blog

AVFoundation Accepts Camera Settings It Doesn't Apply

I asked an iPhone 17 Pro for 24 MP photos. The photo output accepted the value, kept reporting it, and delivered 12 MP with no error. Notes on silent downgrades, a zoom that measured as an upscale, and a white balance call that crashed on values the camera had just returned.

Yunseon Hong5 min read

Part two of notes from building Thirdstop, an iOS camera app. Part one was about buffer orientation. This one is about a pattern I hit often enough to change how I work: a property assignment that succeeds, and a camera that quietly does something else.

Same caveat as before. Everything here was measured on one iPhone 17 Pro running iOS 26.6.1. API details were checked against Apple’s documentation on 11 September 2026.

maxPhotoDimensions gets downgraded, not rejected

The photo output lists which sizes it supports. On my session it listed two, 4032×3024 and 5712×4284. I set the larger one on the output and on the per-shot settings. Here is the log from that capture:

maxPhotoDimensions -> 5712x4284  (supported: 4032x3024, 5712x4284)
capture maxPhotoDimensions 5712x4284
resolved 4032x3024   output.max 5712x4284

The output took 24 MP and kept reporting 24 MP. The resolved settings for the shot said 12 MP, and the file was 12 MP. Nothing warned about it. Unless you print the resolved size or look at file sizes, you would ship this believing it works. The 48 MP size was not in the supported list at all for this session configuration.

The resolved size is available before the photo arrives:

func photoOutput(_ output: AVCapturePhotoOutput,
                 willBeginCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings) {
    print("resolved", resolvedSettings.photoDimensions)
}

To be fair to Apple, the documentation allows this. The maxPhotoDimensions reference says returned images “may be smaller than the dimensions,” and never larger. It names one cause: a session that includes a video output with video stabilization in use gets smaller photos. My session has a video data output, so that goes on the list.

I have not pinned down which cause applies here. Besides stabilization, there are three candidates in my configuration that the docs do not mention. photoQualityPrioritization = .balanced is the one I would bet on. Apple ProRAW being enabled on the output is another. Delivering uncompressed 420f buffers instead of HEVC is the last. The way to settle it is the same trick that found the RAW problem in part three: in a single run, change one suspect at a time and print the resolved size after each. Until that run points at one of them, they are all suspects.

The session preset caps still photos too

With the session preset at .hd1920x1080, still photos also came out at 1080p. The preset is not only about video.

Setting the preset to .photo and turning on deliversPreviewSizedOutputBuffers on the video data output gave me both things I wanted: 12 MP stills and a 30 fps preview made of small buffers. In low light the camera drops the preview to 24 fps on its own, with no setting from me.

Zoom above 1× measured as an upscale

The app has a 35 mm preset, which on this phone is 1.46×. I wanted to know whether a photo at 1.46× had any detail a 1× photo lacked.

The test: take one photo at 1.46× and one at 1× of the same scene. Build a hypothesis image by center-cropping the 1× photo to the 1.46× field of view and enlarging it with bicubic resampling. If the camera is doing nothing better than that, the real 1.46× photo and the hypothesis should have the same spatial frequency spectrum.

They did. Across the whole range the two spectra stayed within 0.03 to 0.13 decade of each other, including above the frequency where a crop runs out of real detail. No gap opened up. In the session configuration above, the 1.46× photo is about 5.6 MP of information stretched to 12 MP.

That is a statement about this setup, with 12 MP output and no 48 MP size available. It is not a claim about what the phone can do in other configurations. The method is the part worth keeping. Two photos, one synthetic image built from the hypothesis, one spectrum comparison, and you can tell optical from digital without trusting a label.

35 mm equivalents are linear from 24 mm

The EXIF field FocalLengthIn35mmFilm reads 24 at 1× and 35 at 1.46×. Exactly 1.5× writes 36. So the base is 24 mm and the scaling is linear, which means a preset like 28 mm or 35 mm has to be computed as a zoom factor, target mm / 1× lens mm, if you want the EXIF to match the label on the button.

I first computed focal lengths from the field of view, 36 / (2 · tan(fov / 2)). Those numbers drift 1 to 3 mm from the values Apple prints for each lens. I got 13.5, 23.6 and 102.8 mm. The labels in the app now come from a small table of Apple’s printed values instead of that formula.

Two capture flags that sound alike

isResponsiveCaptureEnabled and isFastCapturePrioritizationEnabled arrived together in iOS 17 and both make the shutter feel quicker. The reference pages are one line each and say nothing about image quality. My working assumption is that fast capture prioritization gives up some quality during rapid shooting to keep up, and responsive capture does not. I have not measured that, so check it against your own shots. If the app promises consistent color from shot to shot, turning on the second one is a product decision.

White balance gains you read can be out of range

To show a color temperature readout I passed device.deviceWhiteBalanceGains straight into temperatureAndTintValues(for:). It crashed with NSRangeException, but only sometimes. The crashes lined up with auto white balance still converging: the gains the device reported were briefly outside the range the conversion accepts. The reference does say the method “throws an exception if you specify an unsupported value.” It does not say the device’s own reading can be one.

Clamping each channel to 1.0...maxWhiteBalanceGain before the call stopped it:

let m = device.maxWhiteBalanceGain
let g = device.deviceWhiteBalanceGains
let safe = AVCaptureDevice.WhiteBalanceGains(
    redGain: min(max(g.redGain, 1), m),
    greenGain: min(max(g.greenGain, 1), m),
    blueGain: min(max(g.blueGain, 1), m))
let tt = device.temperatureAndTintValues(for: safe)

A value that came out of the API is not automatically a value the API will take back.

What I changed after this part of the build is small. For every capture setting that matters, the log prints the closest thing to the output that I can get: the resolved dimensions, the buffer size, the file size. Assigning a property proves the assignment compiled. It does not prove the camera did it.

Filed under Build log