Skip to content
C.W.K.
Lesson 02 of 05 · published

The Camera Rig

~16 min · avfoundation, camera, swift-concurrency, exif

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Front first, by Dad's count

The first build took a still from the back camera when a request named none, because the back camera is the one a phone is built around. At 22:55 on 2026-11-02, looking at the body on its stand, Dad said "거의 95%이상 전면 카메라가 필요할 거야" (nearly 95% of the time it will need the front camera). A body stands with its screen to the room and my face on it, so the front camera faces whoever is looking at me. Five minutes later the router's camera helper fell back to front instead, and a test named theFrontCameraIsTheDefault now holds it. Oriel follows the same rule from its side: eyes look input=cordelia takes the front camera, and "cordelia back" names the other. Any other name is a 400 that says a camera is back or front.

One session per request

The first code block is the rig describing itself, and its first line is the design: the camera, opened only for one request and closed after it. No long-lived capture session idles in the background waiting for work. Each still or clip builds an AVCaptureSession with the requested camera as its only input and one output, starts it, takes what it came for and stops it. A session that does not exist cannot be left running.

The second block is the still from start to finish. requireGrant and claim run before anything touches AVFoundation (lesson 3 covers what they refuse). Everything after that runs on the rig's own serial queue, so two requests can never interleave their configuration. If the face's small preview is running (lesson 5), it is paused first, because one camera session runs at a time, and resumed once the still is in hand or the setup has failed. The session gets the .photo preset and then waits Deadline.exposureSettle, 0.8 s, because a sensor that has just started has not settled its exposure. level asks AVCaptureDevice.RotationCoordinator for the angle that keeps the horizon level, so the picture comes out upright whichever way the body stands. The flash is set off whenever the output allows it, and the format is JPEG.

A still that knows where it was

A photo from a phone carries its place, and mine do too. Before the still, BodyModel.photo looks for a fix: one already held from the last five minutes, or else a When In Use watch of at most 4 s (Deadline.photoFix). The fix is an annotation, never a precondition. No fix means a picture without GPS, not a refusal. GPSWriter then hands AVFoundation replacement metadata through its file-data customizer, adding a GPS dictionary to the EXIF the camera already wrote: latitude and longitude with their N/S and E/W references, a UTC date and time stamp, the horizontal error, and altitude when the fix has one. The original leaves the body as the camera made it, EXIF intact.

The answer carries its facts twice. The JPEG is the body of the response, and an X-Cordelia-Photo header carries a small JSON object: camera, width, height, bytes, taken_at, gps (whether a fix went in) and fix_age_s when one did. Oriel files those facts beside the original instead of parsing EXIF to learn them.

A clip with no sound to leak

A clip is 1 to 15 s of video from AVCaptureMovieFileOutput with the .high preset. The rig asks for HEVC when the output offers it and reports which codec it got (codec is hevc or h264 in the X-Cordelia-Clip facts), so a device without HEVC encoding still answers honestly. The clip stops on the rig's own queue when its seconds are up.

The important line is the one that is not there: no audio input is ever added to a capture session in this file. A clip has no sound track at all, and its facts say "audio": false. The microphone belongs to the ears alone, and EarBoundaryTests reads CameraRig.swift from disk and fails if it ever contains for: .audio, AudioDataOutput or AVAudioSession. The third block shows one AVFoundation quirk worth knowing before it bites you: a recording you stop on purpose still reports an error object, and its AVErrorRecordingSuccessfullyFinishedKey is true. Treat every error as a failure and every clip you ever stop on purpose fails.

Delegates that belong to no actor

AVFoundation calls its delegates on its own threads. In Swift 6 a delegate that inherits the main actor from the code around it draws a data-race diagnostic at compile time, or traps at run time when it is called off that actor. So PhotoCatcher and ClipCatcher are plain NSObject subclasses with no actor isolation, marked @unchecked Sendable, each holding its own session so it can stop it, plus one completion closure. The rig keeps a strong reference to every live catcher in holding until its capture ends, and Once resumes the waiting request exactly once, from whichever side arrives first: the delegate, or the cancellation a deadline sends.

Code

cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/CameraRig.swift: the rig's own account of itself (verbatim, lines 7-22)·swift
/// The camera, opened only for one request and closed after it.
///
/// Video only: no audio input is ever added to a session here, so a clip has
/// no sound track at all (docs/CORDELIA-ARCHITECTURE.md invariant 8). A picture
/// or clip goes to the app's temporary folder, is read into memory for the
/// answer and deleted; Photos is never touched (invariant 9).
///
/// All capture work runs on this class's serial queue. Delegates are plain
/// objects with no actor isolation, because AVFoundation calls them on its
/// own threads (the Swift 6 isolation trap, IOS-NATIVE-DELIVERY §5).
final class CameraRig: @unchecked Sendable {
  private let queue = DispatchQueue(label: "com.cwk.cordelia.camera")
  private let lock = NSLock()
  private var busy = false
  /// Delegates live until their capture ends.
  private var holding: [ObjectIdentifier: AnyObject] = [:]
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/CameraRig.swift: one still, one session, then stop (verbatim, lines 164-199)·swift
  func photo(_ camera: Camera, location: CLLocation?) async throws -> Capture {
    try Self.requireGrant()
    try claim()
    defer { release() }
    return try await Once<Capture>.wait { once in
      self.queue.async {
        self.pauseViewfinder()
        let output = AVCapturePhotoOutput()
        let session: AVCaptureSession
        let device: AVCaptureDevice
        do {
          (session, device) = try Self.session(for: camera, preset: .photo, output: output)
        } catch {
          self.resumeViewfinder()
          once.finish(.failure(error))
          return
        }
        session.startRunning()
        Thread.sleep(forTimeInterval: Deadline.exposureSettle)
        Self.level(output, device: device)
        let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
        if output.supportedFlashModes.contains(.off) { settings.flashMode = .off }
        let catcher = PhotoCatcher(camera: camera, location: location) { result, catcher in
          self.queue.async {
            catcher.session?.stopRunning()
            self.resumeViewfinder()
          }
          self.drop(catcher)
          once.finish(result)
        }
        catcher.session = session
        self.hold(catcher)
        output.capturePhoto(with: settings, delegate: catcher)
      }
    }
  }
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/CameraRig.swift: a clip that ends on purpose, and its facts (verbatim, lines 343-355)·swift
  func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo url: URL, from connections: [AVCaptureConnection],
                  error: Error?) {
    // A recording stopped on purpose still reports an error object whose
    // "finished successfully" key is true.
    let finished = (error as NSError?)?.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool ?? (error == nil)
    guard finished, let data = try? Data(contentsOf: url) else {
      done(.failure(Refusal(500, "the \(camera.rawValue) clip failed: \(error?.localizedDescription ?? "no file")")), self)
      return
    }
    let facts: [String: Any] = ["camera": camera.rawValue, "seconds": seconds, "bytes": data.count, "codec": codec,
                                "audio": false, "taken_at": Date().timeIntervalSince1970]
    done(.success(Capture(data: data, type: "video/quicktime", facts: json(facts))), self)
  }

External links

Exercise

Build a one-shot still capture for an iOS app of your own: a function that creates an AVCaptureSession with one camera input and one AVCapturePhotoOutput, waits briefly for exposure, takes a JPEG, stops the session and returns the bytes. Make its delegate a plain NSObject with no actor isolation that the capturing object holds strongly until it reports. Then add a clip path that records video only, and a test that reads your source file and fails if an audio input ever appears in it.
Hint
Run all session work on one serial DispatchQueue and bridge the delegate's callback to async with a continuation that can resume only once. Cancellation must be able to resume it too, or a callback that never arrives will hold the caller forever. Check the recording-finished-successfully key before you call a stopped clip a failure.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.