Claude-skill-registry axiom-camera-capture
AVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferred processing, capture responsiveness, zero-shutter-lag, photoQualityPrioritization, front camera mirroring
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/axiom-camera-capture" ~/.claude/skills/majiayu000-claude-skill-registry-axiom-camera-capture && rm -rf "$T"
skills/data/axiom-camera-capture/SKILL.mdCamera 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
on main thread (blocks UI for seconds)startRunning() - ❌ Using deprecated
instead of RotationCoordinator (iOS 17+)videoOrientation - ❌ Not observing session interruptions (app freezes on phone call)
- ❌ Creating new AVCaptureSession for each capture (expensive)
- ❌ Using
preset for video (wrong format).photo - ❌ Ignoring
(slow captures)photoQualityPrioritization - ❌ Not handling
permission state.notAuthorized - ❌ 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
Wrong:
session.removeInput(oldInput) session.addInput(newInput) // May fail mid-stream
Right:
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:
- Implement interruption handling (30 min)
- Test by calling your test device during camera use
- 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:
- Set
for social/sharing use casesphotoQualityPrioritization = .speed - Consider deferred processing for maximum responsiveness
- 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:
- Explain this is Apple's standard behavior
- If business requires mirrored photos (selfie apps), manually mirror in post-processing
- 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
- ☑
never called on main threadstartRunning() - ☑ Session preset matches use case (
for photos,.photo
for video).high - ☑ Configuration changes wrapped in
/beginConfiguration()commitConfiguration()
Permissions:
- ☑ Camera permission requested before session setup
- ☑
in Info.plistNSCameraUsageDescription - ☑
if recording audioNSMicrophoneUsageDescription - ☑ 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