MyApp/CameraCaptureService.swift
7.8 KB · 155 lines · swift · Download from the repository page
import AVFoundationimport UIKitimport CoreImage /// The actor and AVFoundation delegates share one serial executor. No unchecked/// Sendable conformance or asynchronous transfer of mutable sample buffers is used.actor CameraCaptureService { nonisolated let queue = DispatchSerialQueue(label: "party.capture", qos: .userInitiated) nonisolated var unownedExecutor: UnownedSerialExecutor { queue.asUnownedSerialExecutor() } private let session = AVCaptureSession() private let video = AVCaptureVideoDataOutput() private let audio = AVCaptureAudioDataOutput() private let context = CIContext() private var writer: AVAssetWriter? private var videoInput: AVAssetWriterInput? private var audioInput: AVAssetWriterInput? private var origin: CMTime? private var videoTime: Double = 0 private var audioTime: Double = 0 private var hasVideo = false private var hasAudio = false private var lastPreview: Double = 0 private var recordingURL: URL? private var captureError: String? private var onFrame: (@MainActor @Sendable (UIImage) -> Void)? private lazy var delegate = SampleDelegate { [weak self] output, buffer in self?.assumeIsolated { isolatedSelf in isolatedSelf.receive(output: output, sampleBuffer: buffer) } } func setFrameHandler(_ handler: @escaping @MainActor @Sendable (UIImage) -> Void) { onFrame = handler } func configure(cameraID: String, rotationAngle: Double) throws { guard writer == nil else { throw PartyError.message("Stop recording before changing cameras.") } session.stopRunning() session.beginConfiguration() do { session.inputs.forEach { session.removeInput($0) } session.outputs.forEach { session.removeOutput($0) } session.automaticallyConfiguresApplicationAudioSession = false session.sessionPreset = session.canSetSessionPreset(.hd1920x1080) ? .hd1920x1080 : .high guard let camera = AVCaptureDevice(uniqueID: cameraID), let mic = AVCaptureDevice.default(for: .audio) else { throw PartyError.message("Camera or microphone unavailable. Use nonrecording mode.") } for device in [camera, mic] { let input = try AVCaptureDeviceInput(device: device) guard session.canAddInput(input) else { throw PartyError.message("Capture input unavailable.") } session.addInput(input) } for output in [video as AVCaptureOutput, audio as AVCaptureOutput] { guard session.canAddOutput(output) else { throw PartyError.message("Capture output unavailable.") } session.addOutput(output) } video.alwaysDiscardsLateVideoFrames = true video.setSampleBufferDelegate(delegate, queue: queue) audio.setSampleBufferDelegate(delegate, queue: queue) if let connection = video.connection(with: .video), connection.isVideoRotationAngleSupported(rotationAngle) { connection.videoRotationAngle = rotationAngle } if camera.activeFormat.videoSupportedFrameRateRanges.contains(where: { $0.minFrameRate <= 30 && $0.maxFrameRate >= 30 }) { try camera.lockForConfiguration() camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 30) camera.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 30) camera.unlockForConfiguration() } session.commitConfiguration() } catch { session.commitConfiguration() throw error } session.startRunning() } func start(url: URL) throws { guard session.isRunning, writer == nil else { throw PartyError.message("Camera is not ready.") } guard let videoSettings = video.recommendedVideoSettingsForAssetWriter(writingTo: .mov), let audioSettings = audio.recommendedAudioSettingsForAssetWriter(writingTo: .mov) else { throw PartyError.message("Recording settings unavailable.") } let writer = try AVAssetWriter(outputURL: url, fileType: .mov) let v = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) let a = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) v.expectsMediaDataInRealTime = true a.expectsMediaDataInRealTime = true guard writer.canAdd(v), writer.canAdd(a) else { throw PartyError.message("Unsupported recording format.") } writer.add(v); writer.add(a) guard writer.startWriting() else { throw writer.error ?? PartyError.message("Recording could not start.") } self.writer = writer; videoInput = v; audioInput = a origin = nil; hasVideo = false; hasAudio = false videoTime = 0; audioTime = 0; captureError = nil recordingURL = url } func timeline() throws -> Double { if let captureError { throw PartyError.message(captureError) } guard writer?.status == .writing else { throw PartyError.message("Recording stopped unexpectedly.") } return hasVideo && hasAudio ? min(videoTime, audioTime) : 0 } func finish() async throws -> URL { guard let writer, let url = recordingURL else { throw PartyError.message("No recording to finish.") } let valid = hasVideo && hasAudio && captureError == nil self.writer = nil videoInput?.markAsFinished(); audioInput?.markAsFinished() await writer.finishWriting() guard writer.status == .completed && valid else { throw writer.error ?? PartyError.message("Incomplete capture; attempt voided.") } return url } func stop() { writer?.cancelWriting(); writer = nil session.stopRunning() } private func receive(output: AVCaptureOutput, sampleBuffer: CMSampleBuffer) { let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) let isVideo = output === video // Recording gets first priority. Preview is deliberately throttled to 10 fps. if let writer, writer.status == .writing { if origin == nil, isVideo { origin = pts; writer.startSession(atSourceTime: pts) } if let origin, pts >= origin { let elapsed = (pts - origin).seconds if elapsed >= 20 { captureError = "20-second recording limit reached."; writer.cancelWriting(); return } let input = isVideo ? videoInput : audioInput if let input, input.isReadyForMoreMediaData, input.append(sampleBuffer) { if isVideo { videoTime = elapsed; hasVideo = true } else { audioTime = elapsed; hasAudio = true } } else { captureError = "Recording could not keep up. Please retry." } } } if isVideo, pts.seconds - lastPreview > 0.10, let buffer = CMSampleBufferGetImageBuffer(sampleBuffer) { lastPreview = pts.seconds let source = CIImage(cvPixelBuffer: buffer) let scaled = source.transformed(by: CGAffineTransform(scaleX: 0.45, y: 0.45)) if let cg = context.createCGImage(scaled, from: scaled.extent) { let image = UIImage(cgImage: cg) let callback = onFrame Task { @MainActor in callback?(image) } } } }} private final class SampleDelegate: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate { let handler: @Sendable (AVCaptureOutput, CMSampleBuffer) -> Void init(handler: @escaping @Sendable (AVCaptureOutput, CMSampleBuffer) -> Void) { self.handler = handler } func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { handler(output, sampleBuffer) }}