home / skills / charleswiltgen / axiom / axiom-camera-capture

This skill helps you implement a production-ready camera capture UX with AVFoundation, including session management, rotation handling, and zero-lag photo

npx playbooks add skill charleswiltgen/axiom --skill axiom-camera-capture

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
28.4 KB
---
name: axiom-camera-capture
description: AVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferred processing, capture responsiveness, zero-shutter-lag, photoQualityPrioritization, front camera mirroring
license: MIT
compatibility: iOS 17+, iPadOS 17+, macOS 14+, tvOS 17+, axiom-visionOS 1+
metadata:
  version: "1.0.0"
  last-updated: "2026-01-03"
---

# Camera Capture with AVFoundation

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 `videoOrientation` instead of RotationCoordinator (iOS 17+)
- ❌ Not observing session interruptions (app freezes on phone call)
- ❌ Creating new AVCaptureSession for each capture (expensive)
- ❌ Using `.photo` preset for video (wrong format)
- ❌ Ignoring `photoQualityPrioritization` (slow captures)
- ❌ Not handling `.notAuthorized` permission 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

```swift
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**:
```xml
<key>NSCameraUsageDescription</key>
<string>Take photos and videos</string>
```

For audio (video recording):
```xml
<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.

```swift
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.

```swift
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.

```swift
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**:
```swift
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.

```swift
// 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:

```swift
// 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):

```swift
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.

```swift
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:

```swift
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:

```swift
// Check support and enable deferred processing
if photoOutput.isAutoDeferredPhotoDeliverySupported {
    photoOutput.isAutoDeferredPhotoDeliveryEnabled = true
}
```

**Delegate callbacks with deferred processing**:

```swift
// 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**:

```swift
// 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.

```swift
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.

```swift
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.

```swift
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**:
```swift
func startCamera() {
    session.startRunning()  // Blocks UI for 1-3 seconds!
}
```

**Right**:
```swift
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):
```swift
// Manually tracking orientation
NotificationCenter.default.addObserver(
    forName: UIDevice.orientationDidChangeNotification,
    object: nil,
    queue: .main
) { _ in
    // Manual rotation logic...
}
```

**Right** (iOS 17+):
```swift
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**:
```swift
// No interruption handling - camera freezes on phone call
```

**Right**:
```swift
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

**Wrong**:
```swift
session.removeInput(oldInput)
session.addInput(newInput)  // May fail mid-stream
```

**Right**:
```swift
session.beginConfiguration()
session.removeInput(oldInput)
session.addInput(newInput)
session.commitConfiguration()  // Atomic change
```

**Why it matters**: Without configuration block, session may enter invalid state between calls.

## Pressure Scenarios

### Scenario 1: "Just Make the Camera Work by Friday"

**Context**: Product wants camera feature shipped. You're considering skipping interruption handling.

**Pressure**: "It works when I test it, let's ship."

**Reality**: First user who gets a phone call while using camera will see frozen UI. App Store review may catch this.

**Correct action**:
1. Implement interruption handling (30 min)
2. Test by calling your test device during camera use
3. Verify UI shows appropriate feedback

**Push-back template**: "Camera captures work, but the app freezes if a phone call comes in. I need 30 minutes to handle interruptions properly and avoid 1-star reviews."

### Scenario 2: "The Camera is Too Slow"

**Context**: QA reports photo capture feels sluggish. PM wants it "instant like the system camera."

**Pressure**: "Just make it faster somehow."

**Reality**: Default settings prioritize quality over speed. System camera uses deferred processing.

**Correct action**:
1. Set `photoQualityPrioritization = .speed` for social/sharing use cases
2. Consider deferred processing for maximum responsiveness
3. Show capture animation immediately (before processing completes)

**Push-back template**: "We're currently optimizing for image quality. I can make capture feel instant by prioritizing speed and showing the preview immediately while processing continues in background. This is what the system Camera app does."

### Scenario 3: "Why is the Front Camera Photo Mirrored?"

**Context**: Designer reports front camera photos look "wrong" - they're not mirrored like the preview.

**Pressure**: "The preview shows it one way, the photo should match."

**Reality**: Preview is mirrored (user expectation - like a mirror). Photo is NOT mirrored (correct for sharing - text reads correctly). This is intentional behavior matching system camera.

**Correct action**:
1. Explain this is Apple's standard behavior
2. If business requires mirrored photos (selfie apps), manually mirror in post-processing
3. Never mirror the preview differently than expected

**Push-back template**: "This is intentional Apple behavior. The preview is mirrored like a mirror so users can frame themselves, but the captured photo is unmirrored so text reads correctly when shared. We can add optional mirroring in post-processing if our use case requires it."

## Checklist

Before shipping camera features:

**Session Setup**:
- ☑ All session work on dedicated serial queue
- ☑ `startRunning()` never called on main thread
- ☑ Session preset matches use case (`.photo` for photos, `.high` for video)
- ☑ Configuration changes wrapped in `beginConfiguration()`/`commitConfiguration()`

**Permissions**:
- ☑ Camera permission requested before session setup
- ☑ `NSCameraUsageDescription` in Info.plist
- ☑ `NSMicrophoneUsageDescription` if recording audio
- ☑ Graceful handling of denied permission

**Rotation**:
- ☑ RotationCoordinator used (not deprecated videoOrientation)
- ☑ Preview layer rotation updated via observation
- ☑ Capture rotation angle applied when taking photos
- ☑ Tested in all orientations (portrait, landscape, face-up)

**Responsiveness**:
- ☑ photoQualityPrioritization set appropriately for use case
- ☑ Capture button shows immediate feedback
- ☑ Deferred processing considered for maximum speed

**Interruptions**:
- ☑ Session interruption observer registered
- ☑ UI feedback shown when interrupted
- ☑ Tested with incoming phone call
- ☑ Tested in Split View (iPad)

**Camera Switching**:
- ☑ Front/back switch updates rotation coordinator
- ☑ Switch happens on session queue
- ☑ Fallback if new camera unavailable

**Video Recording** (if applicable):
- ☑ Microphone input added
- ☑ Recording delegate handles completion
- ☑ File cleanup for temporary recordings

## Resources

**WWDC**: 2021-10247, 2023-10105

**Docs**: /avfoundation/avcapturesession, /avfoundation/avcapturedevice/rotationcoordinator, /avfoundation/avcapturephotosettings, /avfoundation/avcapturephotooutputreadinesscoordinator

**Skills**: axiom-camera-capture-ref, axiom-camera-capture-diag, axiom-photo-library

Overview

This skill teaches building a battle-tested custom camera using AVFoundation for modern xOS apps. It focuses on session setup, real-time preview, high-quality photo capture, video recording with audio, and robust lifecycle handling. The goal is a responsive, low-latency camera UX that handles rotation, interruptions, and device differences.

How this skill works

The skill walks through creating an AVCaptureSession with dedicated serial queue work, adding camera and microphone inputs, and configuring photo and movie outputs. It covers SwiftUI preview embedding, RotationCoordinator for correct orientation, and iOS 17+ APIs for zero-shutter-lag, responsive capture, deferred processing, and readiness coordination. It also explains permission handling, session start/stop patterns, and safe session modifications.

When to use it

  • You need a custom camera UI instead of the system picker
  • Capture photos with tuned speed vs quality tradeoffs
  • Record video with audio and correct format handling
  • Ensure correct preview and capture orientation across rotations
  • Improve perceived capture responsiveness (zero-shutter-lag)

Best practices

  • Perform all session configuration and start/stop on a dedicated serial queue, never the main thread
  • Request and handle camera and microphone permissions before setup; add Info.plist keys
  • Use RotationCoordinator (iOS 17+) instead of manual videoOrientation changes
  • Use AVCapturePhotoOutput readiness coordinator to update shutter state synchronously
  • Choose photoQualityPrioritization based on use case (.speed, .balanced, .quality) and enable deferred processing where supported

Example use cases

  • SwiftUI camera view showing live AVCaptureVideoPreviewLayer and start/stop on appear/disappear
  • High-speed social app capture using zero-shutter-lag and fast-capture prioritization
  • Document scanning mode that forces .quality photoQualityPrioritization and high-resolution capture
  • Video recorder that adds microphone input and uses AVCaptureMovieFileOutput with correct session preset
  • Camera app that handles interruptions and resumes session reliably after phone calls

FAQ

Do I always need AVFoundation for photos?

No. If you only need a simple picker, use PHPicker/PhotosPicker or UIImagePickerController. Use AVFoundation when you need a custom UI or finer control.

Why must session work not run on the main thread?

session.startRunning()/stopRunning() block and can freeze the UI for seconds; run them on a dedicated serial queue to keep the app responsive.

When should I enable zero-shutter-lag and deferred processing?

Enable ZSL and responsive capture on supported devices for instant-feeling captures. Use deferred processing to return quick proxies while heavy processing completes in background; watch memory and special-case features like flash or manual exposure.