Swift 录制视频实例

2024-04-05  本文已影响0人  大成小栈

Swift 中,使用 AVCaptureSession + AVCaptureMovieFileOutput 录制视频实例:

import Photos
import PhotosUI
import AVFoundation

class VideoRecorder: NSObject {
    
    // 结束回调
    typealias VideoRecorderCallback = (_ didFinish: Bool, _ videoUrl: URL, _ progress: CGFloat) -> Void
    // 翻转回调
    typealias SwapCallback = ((_ position: AVCaptureDevice.Position) -> Void)
    // 会话错误回调
    typealias ErrorCallback = () -> Void
    
    // 录制过程回调
    var recordAction: VideoRecorderCallback?
    // 录制过程出错
    var errorAction: ErrorCallback?
    // 翻转回调
    var swapAction: SwapCallback?
    
    // 视频捕获会话,协调input和output间的数据传输
    let captureSession = AVCaptureSession()
    // 将捕获到的视频输出到文件
    let fileOut = AVCaptureMovieFileOutput()
    // 视频输入
    private var videoInput: AVCaptureDeviceInput?
    // 预览视图
    lazy var previewLayer: AVCaptureVideoPreviewLayer = .init(session: captureSession)
    // 串行队列
    private let serialQueue = DispatchQueue(label: "VideoRecorderQueue")
    // 记录当前设备是横是竖
    var orientation: OrientationDetector.ScreenOrientation = .portrait
    // 记录当前摄像头是前是后
    private var position: AVCaptureDevice.Position = .front
    
    // 视频输入设备,前后摄像头
    var camera: AVCaptureDevice?
    // 录制计时
    private var timer: Timer?
    // 文件存储位置url
    private var fileUrl: URL = VideoRecorder.newVideoUrl()
    // 超时后自动停止
    let limitDuration: CGFloat = 59.9
    // 设置帧率
    var frameDuration = CMTime(value: 1, timescale: 25)
    // 设置码率
    var bitRate: Int = 2000 * 1024
    // 摄像头正在翻转
    var isSwapping: Bool = false
    
    // 是否正在录制
    var isRecording: Bool = false
    
    // MARK: - Life Cycle
    
    deinit {
        sessionStopRunning()
        NotificationCenter.default.removeObserver(self)
        print("Running ☠️ \(Self.self) 💀 deinit")
    }
    
    override init() {
        super.init()
        clearOldFile()
        initCaptureSession()
        sessionStartRunning()
    }
    
    private func clearOldFile() {
        if FileManager.default.fileExists(atPath: fileUrl.path) {
            try? FileManager.default.removeItem(at: fileUrl)
        }
    }
    
    private func initCaptureSession() {
        guard let newCamera = getCamera(with: position) else {
            errorAction?()
            return
        }
        camera = newCamera
        serialQueue.async { [weak self] in
            guard let self else { return }
            captureSession.beginConfiguration()
            configureSessionPreset(for: newCamera)
            sessionAddOutput()
            sessionAddInput(for: newCamera)
            captureSession.commitConfiguration()
        }
    }
    
    private func configureSessionPreset(for device: AVCaptureDevice) {
        if device.supportsSessionPreset(.hd1280x720) {
            captureSession.sessionPreset = .hd1280x720
        } else {
            captureSession.sessionPreset = .high
        }
    }
    
    private func sessionRemoveInputs() {
        if let allInputs = captureSession.inputs as? [AVCaptureDeviceInput] {
            for input in allInputs {
                captureSession.removeInput(input)
            }
        }
    }
    
    private func sessionAddOutput() {
        // 视频输出文件
        addOutput(fileOut)
        // 输出视频的码率
        setBitRate(fileOut: fileOut)
    }
    
    private func sessionAddInput(for camera: AVCaptureDevice) {
        // 加音频设备
        if let audioDevice = AVCaptureDevice.default(for: .audio) {
            addInput(for: audioDevice)
        }
        // 加摄像头
        configCamera(camera)
        addInput(for: camera)
        setupFileOutConnection()
    }
    
    private func setupFileOutConnection() {
        if let connection = fileOut.connection(with: .video) {
            switch orientation {
            case .landscapeLeft:
                connection.videoOrientation = .landscapeRight
            case .landscapeRight:
                connection.videoOrientation = .landscapeLeft
            case .portraitUpsideDown:
                connection.videoOrientation = .portraitUpsideDown
            default:
                connection.videoOrientation = .portrait
            }
        }
    }
    
    private func addInput(for device: AVCaptureDevice) {
        do {
            let input = try AVCaptureDeviceInput(device: device)
            if captureSession.canAddInput(input) {
                captureSession.addInput(input)
                // 更新全局变量
                videoInput = input
            } else {
                errorAction?()
            }
        } catch {
            errorAction?()
        }
    }
    
    private func addOutput(_ output: AVCaptureOutput) {
        if captureSession.canAddOutput(output) {
            captureSession.addOutput(output)
        } else {
            errorAction?()
        }
    }
    
    private func configCamera(_ camera: AVCaptureDevice) {
        do {
            try camera.lockForConfiguration()
            camera.activeVideoMinFrameDuration = frameDuration
            camera.activeVideoMaxFrameDuration = frameDuration
            if camera.isSmoothAutoFocusSupported {
                camera.isSmoothAutoFocusEnabled = true
            }
            if camera.isFocusPointOfInterestSupported && camera.isFocusModeSupported(.continuousAutoFocus) {
                camera.focusMode = .continuousAutoFocus
            }
            camera.unlockForConfiguration()
        } catch {
            errorAction?()
        }
    }
    
    private func setBitRate(fileOut: AVCaptureMovieFileOutput) {
        if let connection = fileOut.connection(with: .video) {
            let compressionSettings: [String: Any] = [AVVideoAverageBitRateKey: bitRate]
            let codecSettings: [String: Any] = [AVVideoCodecKey: AVVideoCodecType.h264,
                                                AVVideoCompressionPropertiesKey: compressionSettings]
            fileOut.setOutputSettings(codecSettings, for: connection)
        }
    }
    
    // MARK: - swap Camera
    
    private func getCamera(with position: AVCaptureDevice.Position) -> AVCaptureDevice? {
        let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera],
                                                                  mediaType: .video,
                                                                   position: .unspecified)
        for item in discoverySession.devices where item.position == position {
            return item
        }
        return nil
    }
    
    private func transAnimate() {
        let transition = CATransition()
        transition.duration = 0.4
        transition.delegate = self
        transition.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
        transition.type = CATransitionType(rawValue: "flip")
        if camera?.position == .front {
            transition.subtype = .fromLeft
        } else {
            transition.subtype = .fromRight
        }
        previewLayer.add(transition, forKey: "changeCamera")
    }
    
    func swapCamera(callback: ((_ position: AVCaptureDevice.Position) -> Void)?) {
        guard !isSwapping else { return }
        
        isSwapping = true
        captureSession.stopRunning()
        swapAction = callback
        
        serialQueue.sync { [weak self] in
            guard let self else { return }
            captureSession.beginConfiguration()
            sessionRemoveInputs()
            let toPosition: AVCaptureDevice.Position = position == .back ? .front : .back
            if let newCamera = getCamera(with: toPosition) {
                camera = newCamera
                sessionRemoveInputs()
                sessionAddInput(for: newCamera)
                position = toPosition
            }
            captureSession.commitConfiguration()
        }
        
        transAnimate()
    }
    
    // MARK: - flash Light
    
    func setFlash(callback: ((_ torchMode: AVCaptureDevice.TorchMode) -> Void)?) {
        guard let camera = getCamera(with: .back) else { return }
        do {
            try camera.lockForConfiguration()
            if camera.torchMode == AVCaptureDevice.TorchMode.off {
                camera.torchMode = AVCaptureDevice.TorchMode.on
                callback?(.on)
            } else {
                camera.torchMode = AVCaptureDevice.TorchMode.off
                callback?(.off)
            }
            camera.unlockForConfiguration()
        } catch let error as NSError {
            print("setFlash Error: \(error)")
        }
    }
    
    // MARK: - timer
    
    func resumeTimer() {
        cancelTimer()
        timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            let duration: CGFloat = fileOut.recordedDuration.seconds
            if duration >= limitDuration {
                stopRecording()
            } else {
                recordAction?(false, fileUrl, duration / limitDuration)
            }
        }
    }

    func cancelTimer() {
        timer?.invalidate()
        timer = nil
    }
    
    // MARK: - Actions
    
    func startRecording() {
        if !isRecording, !isSwapping {
            isRecording = true
            setupFileOutConnection()
            if !captureSession.isRunning {
                sessionStartRunning()
            }
            serialQueue.async { [weak self] in
                guard let self = self else { return }
                fileOut.startRecording(to: fileUrl, recordingDelegate: self)
            }
            resumeTimer()
        }
    }

    func stopRecording() {
        if isRecording, !isSwapping {
            isRecording = false
            cancelTimer()
            serialQueue.async { [weak self] in
                guard let self = self else { return }
                fileOut.stopRecording()
            }
        }
    }

    func sessionStartRunning() {
        serialQueue.async { [weak self] in
            guard let self else { return }
            if !captureSession.isRunning {
                captureSession.startRunning()
            }
        }
    }

    func sessionStopRunning() {
        stopRecording()
        captureSession.stopRunning()
    }
    
}

// MARK: - CAAnimationDelegate
extension VideoRecorder: CAAnimationDelegate {
    
    func animationDidStart(_ anim: CAAnimation) {
        sessionStartRunning()
    }
    
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        isSwapping = false
        if let position = videoInput?.device.position {
            swapAction?(position)
        }
    }
}

// MARK: - AVCaptureFileOutputRecordingDelegate

extension VideoRecorder: AVCaptureFileOutputRecordingDelegate {
    
    func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) {
        recordAction?(false, fileURL, 0.0)
    }
    
    func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
        let duration: CGFloat = fileOut.recordedDuration.seconds
        if position == .front {
            HXFFmpegUtil.flipVideo(withInputUrl: outputFileURL, outputUrl: VideoRecorder.newVideoUrl()) { [weak self] success, url in
                guard let self else { return }
                if success, let url {
                    recordAction?(true, url, duration / limitDuration)
                    clearOldFile()
                } else {
                    recordAction?(true, outputFileURL, duration / limitDuration)
                }
            }
        } else {
            recordAction?(true, outputFileURL, duration / limitDuration)
        }
    }
    
    static func newVideoUrl() -> URL {
        do {
            return try AppDatabase.shared.folder(for: .newDidVideo).appendingPathComponent("video_\(UUID().uuidString).mp4")
        } catch {
            HXLogger.info("createDirectory error")
        }
        return URL(fileURLWithPath: "\(NSHomeDirectory())/video_\(UUID().uuidString).mp4")
    }
}

///// test
//extension VideoRecorder {
//    
//    func saveVideoToAlbum() {
//        PHPhotoLibrary.shared().performChanges({ [weak self] in
//            guard let self else { return }
//            PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
//        }) { (success, err) in
//            if success {
//                HXLogger.info(">>>>> save video success")
//            }
//        }
//        
//    }
//    
//}

其中,前置摄像头时可通过fileOut.connection来设置输出视频的镜像形式,但是所得结果总是跟我的期望不一致。索性就不主动设置了,直接使用HXFFmpegUtil.flipVideo翻转,完美解决。

上一篇下一篇

猜你喜欢

热点阅读