This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
891 lines
28 KiB
Markdown
891 lines
28 KiB
Markdown
---
|
|
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
|