Loading...
Loading...
AVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferred processing, capture responsiveness, zero-shutter-lag, photoQualityPrioritization, front camera mirroring
npx skill4agent add charleswiltgen/axiom axiom-camera-capturestartRunning()videoOrientation.photophotoQualityPrioritization.notAuthorizedbeginConfiguration()commitConfiguration()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 skillimport 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
}
}<key>NSCameraUsageDescription</key>
<string>Take photos and videos</string><key>NSMicrophoneUsageDescription</key>
<string>Record audio with video</string>AVCaptureSession
├─ Inputs
│ ├─ AVCaptureDeviceInput (camera)
│ └─ AVCaptureDeviceInput (microphone, for video)
│
├─ Outputs
│ ├─ AVCapturePhotoOutput (photos)
│ ├─ AVCaptureMovieFileOutput (video files)
│ └─ AVCaptureVideoDataOutput (raw frames)
│
└─ Connections (automatic between compatible input/output)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()
}
}
}
}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() }
}
}videoOrientationimport 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
}
}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)
}// 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
}// Check support first
if photoOutput.isZeroShutterLagSupported {
photoOutput.isZeroShutterLagEnabled = true // Required for responsive capture
if photoOutput.isResponsiveCaptureSupported {
photoOutput.isResponsiveCaptureEnabled = true
}
}if photoOutput.isFastCapturePrioritizationSupported {
photoOutput.isFastCapturePrioritizationEnabled = true
// When enabled, rapid captures use "balanced" quality instead of "quality"
// to maintain consistent shot-to-shot time
}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
}
}
}
}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)
}// Check support and enable deferred processing
if photoOutput.isAutoDeferredPhotoDeliverySupported {
photoOutput.isAutoDeferredPhotoDeliveryEnabled = true
}// 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)
}
}
}// 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)
}
}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) }
}
}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)
}
}
}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)
}
}func startCamera() {
session.startRunning() // Blocks UI for 1-3 seconds!
}func startCamera() {
sessionQueue.async { [self] in
session.startRunning()
}
}startRunning()// Manually tracking orientation
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: nil,
queue: .main
) { _ in
// Manual rotation logic...
}let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview)
// Automatically tracks gravity, provides angles// No interruption handling - camera freezes on phone callNotificationCenter.default.addObserver(
forName: .AVCaptureSessionWasInterrupted,
object: session,
queue: .main
) { notification in
// Show UI feedback
}session.removeInput(oldInput)
session.addInput(newInput) // May fail mid-streamsession.beginConfiguration()
session.removeInput(oldInput)
session.addInput(newInput)
session.commitConfiguration() // Atomic changephotoQualityPrioritization = .speedstartRunning().photo.highbeginConfiguration()commitConfiguration()NSCameraUsageDescriptionNSMicrophoneUsageDescription