Camera Capture with AVFoundation
SkillMediaAVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferred processing, capture responsiveness, zero-shutter-lag, photoQualityPrioritization, front camera mirroring
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Camera Capture with AVFoundation skill
What this skill tells your AI
The instructions your AI receives, as published by comeonoliver/skillshub in skills/CharlesWiltgen/Axiom/axiom-camera-capture/SKILL.md and read by ahel’s review.
Guides you through implementing camera capture: session setup, photo capture, video recording, responsive capture UX, rotation handling, and session lifecycle management.
When to Use This Skill
Use when you need to:
- ☑ Build a custom camera UI (not system picker)
- ☑ Capture photos with quality/speed tradeoffs
- ☑ Record video with audio
- ☑ Handle device rotation correctly (RotationCoordinator)
- ☑ Make capture feel responsive (zero-shutter-lag)
- ☑ Handle session interruptions (phone calls, multitasking)
- ☑ Switch between front/back cameras
- ☑ Configure capture quality and resolution
Example Prompts
"How do I set up a camera preview in SwiftUI?" "My camera freezes when I get a phone call" "The photo preview is rotated wrong on front camera" "How do I make photo capture feel instant?" "Should I use deferred processing?" "My camera takes too long to capture" "How do I switch between front and back cameras?" "How do I record video with audio?"
Red Flags
Signs you're making this harder than it needs to be:
- ❌ Calling
startRunning()on main thread (blocks UI for seconds) - ❌ Using deprecated
videoOrientationinstead of RotationCoordinator (iOS 17+) - ❌ Not observing session interruptions (app freezes on phone call)
- ❌ Creating new AVCaptureSession for each capture (expensive)
- ❌ Using
.photopreset for video (wrong format) - ❌ Ignoring
photoQualityPrioritization(slow captures) - ❌ Not handling
.notAuthorizedpermission state - ❌ Modifying session without
beginConfiguration()/commitConfiguration() - ❌ Using UIImagePickerController for custom camera UI (limited control)
Mandatory First Steps
Before implementing any camera feature:
1. Choose Your Capture Mode
What do you need?
┌─ Just let user pick a photo?
│ └─ Don't use AVFoundation - use PHPicker or PhotosPicker
│ See: /skill axiom-photo-library
│
├─ Simple photo/video capture with system UI?
│ └─ UIImagePickerController (but limited customization)
│
├─ Custom camera UI with photo capture?
│ └─ AVCaptureSession + AVCapturePhotoOutput
│ → Continue with this skill
│
├─ Custom camera UI with video recording?
│ └─ AVCaptureSession + AVCaptureMovieFileOutput
│ → Continue with this skill
│
└─ Both photo and video in same session?
└─ AVCaptureSession + both outputs
→ Continue with this skill
2. Request Camera Permission
import AVFoundation
func requestCameraAccess() async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
return true
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .video)
case .denied, .restricted:
// Show settings prompt
return false
@unknown default:
return false
}
}
Info.plist required:
<key>NSCameraUsageDescription</key>
<string>Take photos and videos</string>
For audio (video recording):
<key>NSMicrophoneUsageDescription</key>
<string>Record audio with video</string>
3. Understand Session Architecture
AVCaptureSession
├─ Inputs
│ ├─ AVCaptureDeviceInput (camera)
│ └─ AVCaptureDeviceInput (microphone, for video)
│
├─ Outputs
│ ├─ AVCapturePhotoOutput (photos)
│ ├─ AVCaptureMovieFileOutput (video files)
│ └─ AVCaptureVideoDataOutput (raw frames)
│
└─ Connections (automatic between compatible input/output)
Key rule: All session configuration happens on a dedicated serial queue, never main thread.
Core Patterns
Pattern 1: Basic Session Setup
Use case: Set up camera preview with photo capture capability.
import AVFoundation
class CameraManager: NSObject {
let session = AVCaptureSession()
let photoOutput = AVCapturePhotoOutput()
// CRITICAL: Dedicated serial queue for session work
private let sessionQueue = DispatchQueue(label: "camera.session")
func setupSession() {
sessionQueue.async { [self] in
session.beginConfiguration()
defer { session.commitConfiguration() }
// 1. Set session preset
session.sessionPreset = .photo
// 2. Add camera input
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera,
for: .video,
position: .back),
let input = try? AVCaptureDeviceInput(device: camera),
session.canAddInput(input) else {
return
}
session.addInput(input)
// 3. Add photo output
guard session.canAddOutput(photoOutput) else { return }
session.addOutput(photoOutput)
// 4. Configure photo output
photoOutput.isHighResolutionCaptureEnabled = true
photoOutput.maxPhotoQualityPrioritization = .quality
}
}
func startSession() {
sessionQueue.async { [self] in
if !session.isRunning {
session.startRunning() // Blocking call - never on main thread!
}
}
}
func stopSession() {
sessionQueue.async { [self] in
if session.isRunning {
session.stopRunning()
}
}
}
}
Cost: 30 min implementation
Pattern 2: SwiftUI Camera Preview
Use case: Display camera preview in SwiftUI view.
import SwiftUI
import AVFoundation
struct CameraPreview: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {}
class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
}
// Usage in SwiftUI
struct CameraView: View {
@StateObject private var camera = CameraManager()
var body: some View {
CameraPreview(session: camera.session)
.ignoresSafeArea()
.onAppear { camera.startSession() }
.onDisappear { camera.stopSession() }
}
}
Cost: 20 min implementation
Pattern 3: Rotation Handling with RotationCoordinator (iOS 17+)
Use case: Keep preview and captured photos correctly oriented regardless of device rotation.
Why RotationCoordinator: Deprecated videoOrientation requires manual observation of device orientation. RotationCoordinator automatically tracks gravity and provides angles.
import AVFoundation
class CameraManager {
private var rotationCoordinator: AVCaptureDevice.RotationCoordinator?
private var rotationObservation: NSKeyValueObservation?
func setupRotationCoordinator(device: AVCaptureDevice, previewLayer: AVCaptureVideoPreviewLayer) {
// Create coordinator with device and preview layer
rotationCoordinator = AVCaptureDevice.RotationCoordinator(
device: device,
previewLayer: previewLayer
)
// Observe preview rotation changes
rotationObservation = rotationCoordinator?.observe(
\.videoRotationAngleForHorizonLevelPreview,
options: [.new]
) { [weak previewLayer] coordinator, _ in
// Update preview layer rotation on main thread
DispatchQueue.main.async {
previewLayer?.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
}
}
// Set initial rotation
previewLayer.connection?.videoRotationAngle = rotationCoordinator!.videoRotationAngleForHorizonLevelPreview
}
func captureRotationAngle() -> CGFloat {
// Use this angle when capturing photos
rotationCoordinator?.videoRotationAngleForHorizonLevelCapture ?? 0
}
}
When capturing:
func capturePhoto() {
let settings = AVCapturePhotoSettings()
// Apply rotation angle from coordinator
if let connection = photoOutput.connection(with: .video) {
connection.videoRotationAngle = captureRotationAngle()
}
photoOutput.capturePhoto(with: settings, delegate: self)
}
Cost: 45 min implementation, prevents 2+ hours debugging rotation issues
Pattern 4: Responsive Capture Pipeline (iOS 17+)
Use case: Make photo capture feel instant with zero-shutter-lag, overlapping captures, and responsive button states.
iOS 17+ introduces four complementary APIs that work together for maximum responsiveness:
4a. Zero Shutter Lag
Uses a ring buffer of recent frames to "time travel" back to the exact moment you tapped the shutter. Enabled automatically for iOS 17+ apps.
// Check if supported for current format
if photoOutput.isZeroShutterLagSupported {
// Enabled by default for apps linking iOS 17+
// Opt out if causing issues:
// photoOutput.isZeroShutterLagEnabled = false
}
Why it matters: Without ZSL, there's a delay between tap and frame capture. For action shots, the moment is already over.
Requirements: iPhone XS and newer. Does NOT apply to flash captures, manual exposure, bracketed captures, or constituent photo delivery.
4b. Responsive Capture (Overlapping Captures)
Allows a new capture to start while the previous one is still processing:
// Check support first
if photoOutput.isZeroShutterLagSupported {
photoOutput.isZeroShutterLagEnabled = true // Required for responsive capture
if photoOutput.isResponsiveCaptureSupported {
photoOutput.isResponsiveCaptureEnabled = true
}
}
Tradeoff: Increases peak memory usage. If your app is memory-constrained, consider leaving disabled.
Requirements: A12 Bionic (iPhone XS) and newer.
4c. Fast Capture Prioritization
Automatically adapts quality when taking multiple photos rapidly (like burst mode):
if photoOutput.isFastCapturePrioritizationSupported {
photoOutput.isFastCapturePrioritizationEnabled = true
// When enabled, rapid captures use "balanced" quality instead of "quality"
// to maintain consistent shot-to-shot time
}
When to enable: User-facing toggle ("Prioritize Faster Shooting" in Camera.app). Off by default because it reduces quality.
4d. Readiness Coordinator (Button State Management)
Critical for UX: Provides synchronous updates for shutter button state without async lag.
class CameraManager {
private var readinessCoordinator: AVCapturePhotoOutputReadinessCoordinator!
func setupReadinessCoordinator() {
readinessCoordinator = AVCapturePhotoOutputReadinessCoordinator(photoOutput: photoOutput)
readinessCoordinator.delegate = self
}
func capturePhoto() {
var settings = AVCapturePhotoSettings()
settings.photoQualityPrioritization = .balanced
// Tell coordinator to track this capture BEFORE calling capturePhoto
readinessCoordinator.startTrackingCaptureRequest(using: settings)
photoOutput.capturePhoto(with: settings, delegate: self)
}
}
extension CameraManager: AVCapturePhotoOutputReadinessCoordinatorDelegate {
func readinessCoordinator(_ coordinator: AVCapturePhotoOutputReadinessCoordinator,
captureReadinessDidChange captureReadiness: AVCapturePhotoOutput.CaptureReadiness) {
DispatchQueue.main.async {
switch captureReadiness {
case .ready:
self.shutterButton.isEnabled = true
self.shutterButton.alpha = 1.0
case .notReadyMomentarily:
// Brief delay - disable to prevent double-tap
self.shutterButton.isEnabled = false
case .notReadyWaitingForCapture:
// Flash is firing - dim button
self.shutterButton.alpha = 0.5
case .notReadyWaitingForProcessing:
// Processing previous photo - show spinner
self.showProcessingIndicator()
case .sessionNotRunning:
self.shutterButton.isEnabled = false
@unknown default:
break
}
}
}
}
Why use Readiness Coordinator: Without it, you'd need to track capture state manually and users might spam the shutter button during processing.
Quality Prioritization (Baseline)
Still useful even without the new APIs:
func capturePhoto() {
var settings = AVCapturePhotoSettings()
// Speed vs Quality tradeoff
// .speed - Fastest capture, lower quality
// .balanced - Good default
// .quality - Best quality, may have delay
settings.photoQualityPrioritization = .speed
// For specific use cases:
// - Social sharing: .speed (users expect instant)
// - Document scanning: .quality (accuracy matters)
// - General photography: .balanced
photoOutput.capturePhoto(with: settings, delegate: self)
}
Deferred Processing (iOS 17+):
For maximum responsiveness, capture returns immediately with proxy image, full Deep Fusion processing happens in background:
// Check support and enable deferred processing
if photoOutput.isAutoDeferredPhotoDeliverySupported {
photoOutput.isAutoDeferredPhotoDeliveryEnabled = true
}
Delegate callbacks with deferred processing:
// Called for BOTH regular photos AND deferred proxies
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?) {
guard error == nil else { return }
// Non-deferred photo - save directly
if !photo.isRawPhoto, let data = photo.fileDataRepresentation() {
savePhotoToLibrary(data)
}
}
// Called ONLY for deferred proxies - save to PhotoKit for later processing
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishCapturingDeferredPhotoProxy deferredPhotoProxy: AVCaptureDeferredPhotoProxy,
error: Error?) {
guard error == nil else { return }
// CRITICAL: Save proxy to library ASAP before app is backgrounded
// App may be force-quit if memory pressure is high during backgrounding
guard let proxyData = deferredPhotoProxy.fileDataRepresentation() else { return }
Task {
try await PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
// Use .photoProxy resource type - triggers deferred processing in Photos
request.addResource(with: .photoProxy, data: proxyData, options: nil)
}
}
}
When final processing happens:
- On-demand when image is requested from PhotoKit
- Or automatically when device is idle (plugged in, not in use)
Fetching images with deferred processing awareness:
// Request with secondary degraded image for smoother UX
let options = PHImageRequestOptions()
options.allowSecondaryDegradedImage = true // New in iOS 17
PHImageManager.default().requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFill,
options: options
) { image, info in
let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false
if isDegraded {
// First: Low quality (immediate)
// Second: Medium quality (new - while processing)
// Third callback will be final quality
self.showTemporaryImage(image)
} else {
// Final quality - processing complete
self.showFinalImage(image)
}
}
Requirements: iPhone 11 Pro and newer. Not used for flash captures or formats that don't benefit from extended processing.
Important considerations:
- Can't apply pixel buffer customizations (filters, metadata changes) to deferred photos
- Use PhotoKit adjustments after processing for edits
- Get proxy into library ASAP - limited time when backgrounded
Cost: 1 hour implementation, prevents "camera feels slow" complaints
Pattern 5: Session Interruption Handling
Use case: Handle phone calls, multitasking, system camera usage.
class CameraManager {
private var interruptionObservers: [NSObjectProtocol] = []
func setupInterruptionHandling() {
// Session was interrupted
let interruptedObserver = NotificationCenter.default.addObserver(
forName: .AVCaptureSessionWasInterrupted,
object: session,
queue: .main
) { [weak self] notification in
guard let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
let interruptionReason = AVCaptureSession.InterruptionReason(rawValue: reason) else {
return
}
switch interruptionReason {
case .videoDeviceNotAvailableInBackground:
// App went to background - normal, will resume
self?.showPausedOverlay()
case .audioDeviceInUseByAnotherClient:
// Another app using audio
self?.showInterruptedBanner("Audio in use by another app")
case .videoDeviceInUseByAnotherClient:
// Another app using camera
self?.showInterruptedBanner("Camera in use by another app")
case .videoDeviceNotAvailableWithMultipleForegroundApps:
// Split View/Slide Over - camera not available
self?.showInterruptedBanner("Camera unavailable in Split View")
case .videoDeviceNotAvailableDueToSystemPressure:
// Thermal state - reduce quality or stop
self?.handleThermalPressure()
@unknown default:
self?.showInterruptedBanner("Camera interrupted")
}
}
interruptionObservers.append(interruptedObserver)
// Session interruption ended
let endedObserver = NotificationCenter.default.addObserver(
forName: .AVCaptureSessionInterruptionEnded,
object: session,
queue: .main
) { [weak self] _ in
self?.hideInterruptedBanner()
self?.hidePausedOverlay()
// Session automatically resumes - no need to call startRunning()
}
interruptionObservers.append(endedObserver)
}
deinit {
interruptionObservers.forEach { NotificationCenter.default.removeObserver($0) }
}
}
Cost: 30 min implementation, prevents "camera freezes" bug reports
Pattern 6: Camera Switching (Front/Back)
Use case: Toggle between front and back cameras.
func switchCamera() {
sessionQueue.async { [self] in
guard let currentInput = session.inputs.first as? AVCaptureDeviceInput else {
return
}
let currentPosition = currentInput.device.position
let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back
guard let newDevice = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: newPosition
) else {
return
}
session.beginConfiguration()
defer { session.commitConfiguration() }
// Remove old input
session.removeInput(currentInput)
// Add new input
do {
let newInput = try AVCaptureDeviceInput(device: newDevice)
if session.canAddInput(newInput) {
session.addInput(newInput)
// Update rotation coordinator for new device
if let previewLayer = previewLayer {
setupRotationCoordinator(device: newDevice, previewLayer: previewLayer)
}
} else {
// Fallback: restore old input
session.addInput(currentInput)
}
} catch {
session.addInput(currentInput)
}
}
}
Front camera mirroring: Front camera preview is mirrored by default (matches user expectation). Captured photos are NOT mirrored (correct for sharing). This is intentional.
Cost: 20 min implementation
Pattern 7: Video Recording
Use case: Record video with audio to file.
class CameraManager: NSObject {
let movieOutput = AVCaptureMovieFileOutput()
private var currentRecordingURL: URL?
func setupVideoRecording() {
sessionQueue.async { [self] in
session.beginConfiguration()
defer { session.commitConfiguration() }
// Set video preset
session.sessionPreset = .high // Or .hd1920x1080, .hd4K3840x2160
// Add microphone input
if let microphone = AVCaptureDevice.default(for: .audio),
let audioInput = try? AVCaptureDeviceInput(device: microphone),
session.canAddInput(audioInput) {
session.addInput(audioInput)
}
// Add movie output
if session.canAddOutput(movieOutput) {
session.addOutput(movieOutput)
}
}
}
func startRecording() {
guard !movieOutput.isRecording else { return }
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mov")
currentRecordingURL = outputURL
// Apply rotation
if let connection = movieOutput.connection(with: .video) {
connection.videoRotationAngle = captureRotationAngle()
}
movieOutput.startRecording(to: outputURL, recordingDelegate: self)
}
func stopRecording() {
guard movieOutput.isRecording else { return }
movieOutput.stopRecording()
}
}
extension CameraManager: AVCaptureFileOutputRecordingDelegate {
func fileOutput(_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?) {
if let error = error {
print("Recording error: \(error)")
return
}
// Video saved to outputFileURL
saveVideoToPhotoLibrary(outputFileURL)
}
}
Cost: 45 min implementation
Anti-Patterns
Anti-Pattern 1: Session Work on Main Thread
Wrong:
func startCamera() {
session.startRunning() // Blocks UI for 1-3 seconds!
}
Right:
func startCamera() {
sessionQueue.async { [self] in
session.startRunning()
}
}
Why it matters: startRunning() is blocking. On main thread, UI freezes.
Anti-Pattern 2: Using Deprecated videoOrientation
Wrong (pre-iOS 17):
// Manually tracking orientation
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: nil,
queue: .main
) { _ in
// Manual rotation logic...
}
Right (iOS 17+):
let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview)
// Automatically tracks gravity, provides angles
Why it matters: RotationCoordinator handles edge cases (face-up, face-down) that manual tracking misses.
Anti-Pattern 3: Ignoring Session Interruptions
Wrong:
// No interruption handling - camera freezes on phone call
Right:
NotificationCenter.default.addObserver(
forName: .AVCaptureSessionWasInterrupted,
object: session,
queue: .main
) { notification in
// Show UI feedback
}
Why it matters: Without handling, camera appears frozen when interrupted.
Anti-Pattern 4: Modifying Session Without Configuration Block
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 63
- Forks
- 22
- Last commit
- Jun 2026
Advanced
- Catalog kind
- skill
- Gateway key
axiom-camera-capture- Source
- github.com/comeonoliver/skillshub