Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://simon-says-duo/MyApp/CameraCaptureService.swift

MyApp/CameraCaptureService.swift

7.8 KB · 155 lines · swift · Download from the repository page

MyApp/CameraCaptureService.swift
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)    }}