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翻转,完美解决。
网友评论