DEV Community

TBDS
TBDS

Posted on

AVCaptureMultiCamSession: the real limits, and why the stock Camera app doesn't offer dual recording

Modern iPhones can genuinely run the front and back cameras at the same time.
AVCaptureMultiCamSession has been in the SDK since iOS 13 and works on A12 and
later hardware. And yet Apple's own Camera app has no dual-recording mode, no
toggle, no hidden gesture.

That is not a product oversight you can route around. iOS has two separate
concepts: a normal AVCaptureSession runs exactly one camera and can switch
between them, and a multi-camera session runs several inputs concurrently. The
second is a different object with different rules, and almost everything you know
about the first one is wrong for it.

Here is what those rules actually cost, from a shipping dual-camera recorder.


The problem

You want one MP4 containing both cameras, composited live, with a preview that
matches the file. The pieces sound routine: two inputs, two outputs, a compositor,
an AVAssetWriter.

Why the obvious approach fails

addInput / addOutput simply fail

The convenience API implicitly forms connections for you. On a multi-cam session
that fails outright. You must add inputs and outputs without connections and wire
AVCaptureConnection objects yourself:

private func addVideoBranch(device: AVCaptureDevice,
                            output: AVCaptureVideoDataOutput,
                            mirrored: Bool) throws {
    let input = try AVCaptureDeviceInput(device: device)
    guard session.canAddInput(input) else { throw ConfigError.cannotAdd("\(device.position) input") }
    session.addInputWithNoConnections(input)

    output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
    output.alwaysDiscardsLateVideoFrames = true
    guard session.canAddOutput(output) else { throw ConfigError.cannotAdd("\(device.position) output") }
    session.addOutputWithNoConnections(output)

    guard let port = input.ports(for: .video,
                                 sourceDeviceType: device.deviceType,
                                 sourceDevicePosition: device.position).first else {
        throw ConfigError.noPort("\(device.position)")
    }

    let conn = AVCaptureConnection(inputPorts: [port], output: output)
    guard session.canAddConnection(conn) else { throw ConfigError.cannotAdd("\(device.position) connection") }
    session.addConnection(conn)

    if conn.isVideoRotationAngleSupported(90) { conn.videoRotationAngle = 90 }
    if mirrored, conn.isVideoMirroringSupported {
        conn.automaticallyAdjustsVideoMirroring = false
        conn.isVideoMirrored = true
    }
}
Enter fullscreen mode Exit fullscreen mode

sessionPreset does not work either

A multi-cam session only accepts .inputPriority. Resolution is chosen per device
by picking an activeFormat, only from formats where isMultiCamSupported == true
— and it must be chosen before addInput:

private func configureFormat(for device: AVCaptureDevice, targetWidth: Int32) throws {
    let multiCamFormats = device.formats.filter { $0.isMultiCamSupported }
    guard !multiCamFormats.isEmpty else { throw ConfigError.noFormat("\(device.position)") }

    func supports30fps(_ f: AVCaptureDevice.Format) -> Bool {
        f.videoSupportedFrameRateRanges.contains { $0.minFrameRate <= 30 && $0.maxFrameRate >= 30 }
    }
    func dims(_ f: AVCaptureDevice.Format) -> CMVideoDimensions {
        CMVideoFormatDescriptionGetDimensions(f.formatDescription)
    }

    let chosen = multiCamFormats.first(where: { dims($0).width == targetWidth && supports30fps($0) })
        ?? multiCamFormats.max(by: { a, b in
            Int(dims(a).width) * Int(dims(a).height) < Int(dims(b).width) * Int(dims(b).height)
        })!

    try device.lockForConfiguration()
    defer { device.unlockForConfiguration() }
    device.activeFormat = chosen
    let duration = CMTime(value: 1, timescale: 30)
    device.activeVideoMinFrameDuration = duration
    device.activeVideoMaxFrameDuration = duration
}
Enter fullscreen mode Exit fullscreen mode

Note filter { $0.isMultiCamSupported }. The set of multi-cam formats is a strict
subset of what the device offers when it is the only camera running. This is the
first place the "why not just do what the Camera app does" answer starts to appear:
the Camera app is not choosing from the same menu.

The hardwareCost gate — and it cannot be pre-checked

This is the constraint that shapes the whole feature. A multi-cam configuration has
a hardwareCost and a systemPressureCost. If hardwareCost > 1.0 you get a
runtime error and the session dies. And the value only reflects the new
configuration after you commit it
. There is no dry run.

So every configuration change has to be: change → commit → read cost → roll back if
over.

session.beginConfiguration()
do {
    try configureFormat(for: back,  targetWidth: r.targetWidth)
    try configureFormat(for: front, targetWidth: r.targetWidth)
} catch {
    session.commitConfiguration()
    errorMessage = L10n.Error.detail(Strings.resolutionFailed, error.localizedDescription)
    await startSession()
    return
}
session.commitConfiguration()

if session.hardwareCost > 1.0 {
    downgraded = true
    applied = previous
    session.beginConfiguration()
    try? configureFormat(for: back,  targetWidth: previous.targetWidth)
    try? configureFormat(for: front, targetWidth: previous.targetWidth)
    session.commitConfiguration()
}
Enter fullscreen mode Exit fullscreen mode

Dual 1080p exceeds the budget easily on many devices. That rollback is not
defensive tidiness — remove it and you ship an app that dies when a user picks a
resolution their phone cannot sustain.

The seamless zoom is not available to you

The stock Camera app's continuous pinch from ultra-wide through wide to telephoto
is built on a virtual capture device that internally owns several physical lenses
and crossfades between them. A multi-cam session connects to specific physical
devices, because that is what the isMultiCamSupported format check operates on. A
virtual device that silently reaches across several lenses is not something that
check can reason about.

The consequence for the UI is that lens choice and zoom become two different
controls, and the lens picker may list fewer lenses than the stock app shows:

/// Running three back lenses at once under MultiCam is impossible and unnecessary —
/// probe only which lenses have a multicam format, then use the chosen one as the
/// single back input.
private func discoverBackLenses() {
    var found: [BackLens] = []
    for lens in BackLens.allCases {
        guard let device = Self.device(for: lens) else { continue }
        guard device.formats.contains(where: { $0.isMultiCamSupported }) else { continue }
        found.append(lens)
    }
    availableBackLenses = found
    if !found.contains(backLens) {
        backLens = found.contains(.wide) ? .wide : (found.first ?? .wide)
    }
}
Enter fullscreen mode Exit fullscreen mode

Front camera zoom, incidentally, is 1.0 and stays 1.0: there is no optical zoom
there, so videoMaxZoomFactor is 1. If the front camera is your main frame,
pinch-to-zoom does nothing, and the honest move is to say so rather than let a
gesture look broken.


What actually works

One clock, one texture

Two camera streams do not arrive in lockstep. The design that avoids an entire
class of bugs is to treat one camera as the clock and hold only the latest frame
from the other, then render one texture that is used for both the preview and
the writer:

func captureOutput(_ output: AVCaptureOutput,
                   didOutput sampleBuffer: CMSampleBuffer,
                   from connection: AVCaptureConnection) {

    if output is AVCaptureAudioDataOutput {
        if recorder.isRecording { recorder.appendAudio(sampleBuffer) }
        return
    }

    guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

    guard isBack(output) else {
        latestFront = pixelBuffer   // front camera: keep only the newest frame
        return
    }

    // the back camera is the clock
    guard let front = latestFront else { return }

    let pool: CVPixelBufferPool? = recorder.isRecording ? recorder.pixelBufferPool : nil
    if let composed = compositor.render(back: pixelBuffer, front: front, recordingPool: pool) {
        recorder.appendVideo(composed, pts: CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
    }
}
Enter fullscreen mode Exit fullscreen mode

The composite is rendered directly into a buffer taken from the asset writer's
pixelBufferPool, and that texture is then sampled to the screen. Preview and
recording are structurally incapable of disagreeing. Splitting this into two
independent render paths as an "optimisation" reintroduces the worst bug class in
this domain: looks right on screen, wrong in the file.

All three outputs share one serial data queue and never touch the main actor.

Treat resilience as four named failure paths

Four things end a dual recording, and each one destroys the file if unhandled:

path consequence if ignored
session interrupted (call, another app takes the camera) AVAssetWriter stuck half-open, corrupt file
mediaServicesWereReset reusing old inputs/outputs silently stops delivering frames — no error, the picture just freezes
thermal state reaches .critical the system kills the session; the whole in-progress take is lost
storage fills writer fails outright, entire recording lost

The mediaServicesWereReset one is the nastiest because there is no error at all —
you must rebuild the whole session. And all automatic stops must funnel through the
same finalize path the user's own stop button uses. A second teardown path is how
you get one code path that saves the file and another that doesn't.

The thermal and storage policies are pure functions, which is the only way they get
tested:

enum CapturePolicy {
    static let videoBitrate: Int = 10_000_000
    static let minBytesToStart: Int64 = 200 * 1024 * 1024
    static let minBytesToContinue: Int64 = 100 * 1024 * 1024

    static func thermalAction(level: ThermalLevel, isRecording: Bool, isHD1080: Bool) -> ThermalAction {
        switch level {
        case .critical:
            return isRecording ? .stopRecording : .none
        case .serious:
            if !isRecording, isHD1080 { return .dropTo720 }
            return .none
        case .nominal, .fair:
            return .none
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The asymmetry is deliberate: at serious, drop to 720p only if you have not
started yet — starting a heavy take on an already-hot phone is how you get an
interrupted one — but if you are already recording, warn rather than yank the
resolution mid-file.

Focus coordinates, the silent inversion

focusPointOfInterest is expressed in the unrotated landscape sensor frame. It
does not follow videoRotationAngle or isVideoMirrored. Converting from a
normalised portrait canvas point:

  • back camera (rotation 90): (u, v) = (y, 1 - x)
  • front camera (rotation 90 + mirrored): (u, v) = (y, x)

Get it backwards and nothing crashes and nothing logs. Tapping the top-left just
focuses the bottom-right. This is why that conversion is a pure function with
dozens of tests — and those tests were mutation-verified: swapping the front/back
formulas makes them fail immediately.

There is still a hole worth being honest about. The tests lock the contract of the
pure function, and the contract assumes both connections set
videoRotationAngle = 90 with the front additionally mirrored. Change those two
lines in the session and every test stays green while focus is inverted on a real
phone.


Costs and boundaries

  • Nothing camera-related can be verified in the simulator. AVCaptureMultiCamSession.isMultiCamSupported is permanently false there, so the app correctly shows an unsupported-device screen. Every capture change needs a real A12-or-later device. (Do not hardcode a model list — that system flag is the only authority. Apple documents A12+, which is where "iPhone XS/XR or newer" comes from, but the flag is what you branch on.)
  • Heat and battery are materially worse than single-camera recording. Two sensors, two ISP paths, a Metal composite per frame and an encoder. Users notice.
  • Layout crops. Side-by-side portrait means fitting a 720×1280 source into a 540×1920 column — 25% is lost from each side. That is arithmetic, not a bug, but it must be designed around.
  • Some things get frozen at record time. Muting is implemented by not creating an audio track at start(), which is more robust than writing a silent track, so it cannot be toggled mid-recording. Aspect ratio likewise cannot change during a take without destroying the writer.
  • Metal has its own traps here. half is a builtin type name and cannot be a variable (the compiler error is wildly misleading); multiple draw calls must use setVertexBytes/setFragmentBytes rather than sharing one MTLBuffer at offset 0; and CVMetalTexture objects must be kept alive until the command buffer completes.
  • iOS 17+ blocks the easy device screenshot path, so visual verification of a camera app is manual. Plan for it.

This is the capture stack behind DualCam, an iOS app that records both cameras into
a single composited file.

Top comments (0)