Swift中AVFoundation同段录制切换摄像头的异常问题
问题描述
我使用Swift的AVFoundation框架开发了一款支持多片段合并的视频录制器,启动录制后停止,再次启动并停止录制,可成功合并两段内容。但遇到异常:
- 先使用前置摄像头录制一段后停止,切换至后置摄像头再录制一段,可成功生成合并后的视频;
- 在同一段录制过程中切换前后摄像头,视频处理会失败,无法生成有效片段。
更新代码
func switchCamera() { if isRecording { stopRecording(shouldToggle: false) } flipCamera() if isRecording { startRecording() } } func stopRecording(shouldToggle: Bool){ output.stopRecording() if shouldToggle { isRecording = false } }
完整实现代码
import SwiftUI import SwiftUI import AVKit import AVFoundation class CameraViewModel: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate { @Published var session = AVCaptureSession() @Published var alert = false @Published var output = AVCaptureMovieFileOutput() @Published var preview: AVCaptureVideoPreviewLayer! @Published var isRecording: Bool = false @Published var recordedURLs: [URL] = [] @Published var previewURL: URL? @Published var showPreview: Bool = false @Published var recordedDuration: CGFloat = 0 @Published var maxDuration: CGFloat = 20 var currentCameraPosition: AVCaptureDevice.Position = .back override init() { super.init() self.checkPermission() self.preview = AVCaptureVideoPreviewLayer(session: session) self.preview.videoGravity = .resizeAspectFill } func flipCamera() { let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .unspecified) let videoDevices = discoverySession.devices guard videoDevices.count > 1 else { return } guard let currentVideoInput = session.inputs.first as? AVCaptureDeviceInput else { return } let newCameraPosition: AVCaptureDevice.Position = (currentCameraPosition == .back) ? .front : .back if let newCamera = videoDevices.first(where: { $0.position == newCameraPosition }) { do { let newVideoInput = try AVCaptureDeviceInput(device: newCamera) session.removeInput(currentVideoInput) if session.canAddInput(newVideoInput) { session.addInput(newVideoInput) currentCameraPosition = newCameraPosition } else { print("Failed to add new camera input") } } catch { print("Error creating new camera input: \(error.localizedDescription)") } } } func checkPermission(){ switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: checkAudioPermission() return case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { (status) in if status { self.checkAudioPermission() } } case .denied: self.alert.toggle() return default: return } } func checkAudioPermission() { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: setUp() return case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { (audioStatus) in if audioStatus { self.setUp() } } case .denied: self.alert.toggle() return default: return } } func setUp(){ do { self.session.beginConfiguration() let cameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) let videoInput = try AVCaptureDeviceInput(device: cameraDevice!) let audioDevice = AVCaptureDevice.default(for: .audio) let audioInput = try AVCaptureDeviceInput(device: audioDevice!) if self.session.canAddInput(videoInput) && self.session.canAddInput(audioInput){ self.session.addInput(videoInput) self.session.addInput(audioInput) } if self.session.canAddOutput(self.output){ self.session.addOutput(self.output) } self.session.commitConfiguration() } catch{ print(error.localizedDescription) } } func startRecording(){ let tempURL = NSTemporaryDirectory() + "\(Date()).mov" output.startRecording(to: URL(fileURLWithPath: tempURL), recordingDelegate: self) isRecording = true } func stopRecording(){ output.stopRecording() isRecording = false } func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { if let error = error { print(error.localizedDescription) return } print(outputFileURL) self.recordedURLs.append(outputFileURL) if self.recordedURLs.count == 1{ self.previewURL = outputFileURL return } let assets = recordedURLs.compactMap { url -> AVURLAsset in return AVURLAsset(url: url) } self.previewURL = nil Task { await mergeVideos(assets: assets) { exporter in exporter.exportAsynchronously { if exporter.status == .failed{ print(exporter.error!) } else{ if let finalURL = exporter.outputURL{ print(finalURL) DispatchQueue.main.async { self.previewURL = finalURL } } } } } } } func mergeVideos(assets: [AVURLAsset],completion: @escaping (_ exporter: AVAssetExportSession)->()) async { let compostion = AVMutableComposition() var lastTime: CMTime = .zero guard let videoTrack = compostion.addMutableTrack(withMediaType: .video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else{return} guard let audioTrack = compostion.addMutableTrack(withMediaType: .audio, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else{return} for asset in assets { do { try await videoTrack.insertTimeRange(CMTimeRange(start: .zero, duration: asset.load(.duration)), of: asset.loadTracks(withMediaType: .video)[0], at: lastTime) if try await !asset.loadTracks(withMediaType: .audio).isEmpty { try await audioTrack.insertTimeRange(CMTimeRange(start: .zero, duration: asset.load(.duration)), of: asset.loadTracks(withMediaType: .audio)[0], at: lastTime) } } catch { print(error.localizedDescription) } do { lastTime = try await CMTimeAdd(lastTime, asset.load(.duration)) } catch { print(error.localizedDescription) } } let tempURL = URL(fileURLWithPath: NSTemporaryDirectory() + "Reel-\(Date()).mp4") let layerInstructions = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) var transform = CGAffineTransform.identity transform = transform.rotated(by: 90 * (.pi / 180)) transform = transform.translatedBy(x: 0, y: -videoTrack.naturalSize.height) layerInstructions.setTransform(transform, at: .zero) let instructions = AVMutableVideoCompositionInstruction() instructions.timeRange = CMTimeRange(start: .zero, duration: lastTime) instructions.layerInstructions = [layerInstructions] let videoComposition = AVMutableVideoComposition() videoComposition.renderSize = CGSize(width: videoTrack.naturalSize.height, height: videoTrack.naturalSize.width) videoComposition.instructions = [instructions] videoComposition.frameDuration = CMTimeMake(value: 1, timescale: 30) guard let exporter = AVAssetExportSession(asset: compostion, presetName: AVAssetExportPresetHighestQuality) else{return} exporter.outputFileType = .mp4 exporter.outputURL = tempURL exporter.videoComposition = videoComposition completion(exporter) } } struct HomeStory: View { @StateObject var cameraModel = CameraViewModel() var body: some View { ZStack(alignment: .bottom) { CameraStoryView() .environmentObject(cameraModel) .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) .padding(.top,10) .padding(.bottom,30) ZStack { Button { if cameraModel.isRecording { cameraModel.stopRecording() } else { cameraModel.startRecording() } } label: { if cameraModel.isRecording { Circle().frame(width: 95, height: 95).foregroundStyle(.red).opacity(0.7) } else { ZStack { Color.gray.opacity(0.001) Circle().stroke(.white, lineWidth: 7).frame(width: 80, height: 80) }.frame(width: 95, height: 95) } } Button { cameraModel.flipCamera() } label: { Image(systemName: "arrow.triangle.2.circlepath.camera") .font(.title) .foregroundColor(.white) .padding() .background(Circle().fill(Color.black.opacity(0.7))) }.offset(x: -100) Button { if let _ = cameraModel.previewURL { cameraModel.showPreview.toggle() } } label: { if cameraModel.previewURL == nil && !cameraModel.recordedURLs.isEmpty { ProgressView().tint(.black) } else { HStack { Text("Preview") Image(systemName: "chevron.right") } .padding() .foregroundColor(.black).font(.body) .background { Capsule().foregroundStyle(.ultraThinMaterial) } } } .padding(.horizontal,20) .padding(.vertical,8) .frame(maxWidth: .infinity,alignment: .trailing) .padding(.trailing) .opacity((cameraModel.previewURL == nil && cameraModel.recordedURLs.isEmpty) || cameraModel.isRecording ? 0 : 1) } .frame(maxHeight: .infinity,alignment: .bottom) .padding(.bottom,10) .padding(.bottom,30) Button { cameraModel.recordedDuration = 0 cameraModel.previewURL = nil cameraModel.recordedURLs.removeAll() } label: { Image(systemName: "xmark") .font(.title) .foregroundColor(.white) } .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topLeading) .padding() .padding(.top) .opacity(!cameraModel.recordedURLs.isEmpty && cameraModel.previewURL != nil && !cameraModel.isRecording ? 1 : 0) } .overlay(content: { if let url = cameraModel.previewURL, cameraModel.showPreview { FinalPreview(url: url, showPreview: $cameraModel.showPreview) .transition(.move(edge: .trailing)) } }) .animation(.easeInOut, value: cameraModel.showPreview) .preferredColorScheme(.dark) } } struct FinalPreview: View { var url: URL @Binding var showPreview: Bool var body: some View { GeometryReader { proxy in let size = proxy.size VideoPlayer(player: AVPlayer(url: url)) .aspectRatio(contentMode: .fill) .frame(width: size.width, height: size.height) .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) .overlay(alignment: .topLeading) { Button { showPreview.toggle() } label: { Label { Text("Back") } icon: { Image(systemName: "chevron.left") } .foregroundColor(.white) } .padding(.leading) .padding(.top,22) } } } } struct CameraStoryView: View { @EnvironmentObject var cameraModel: CameraViewModel var body: some View { GeometryReader { proxy in let size = proxy.size CameraPreview(size: size).environmentObject(cameraModel) } .onReceive(Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()) { _ in if cameraModel.recordedDuration <= cameraModel.maxDuration && cameraModel.isRecording{ cameraModel.recordedDuration += 0.01 } if cameraModel.recordedDuration >= cameraModel.maxDuration && cameraModel.isRecording{ cameraModel.stopRecording() cameraModel.isRecording = false } } } } struct CameraPreview: UIViewRepresentable { @EnvironmentObject var cameraModel : CameraViewModel var size: CGSize func makeUIView(context: Context) -> UIView { let view = UIView(frame: CGRect(origin: .zero, size: size)) guard let preview = cameraModel.preview else { return view } preview.frame = view.bounds preview.videoGravity = .resizeAspectFill view.layer.addSublayer(preview) DispatchQueue.global(qos: .userInitiated).async { if !self.cameraModel.session.isRunning { self.cameraModel.session.startRunning() } } return view } func updateUIView(_ uiView: UIView, context: Context) { } }
异常原因分析
录制停止的异步性未处理
AVCaptureMovieFileOutput.stopRecording()是异步操作,调用后不会立刻完成文件写入。当前switchCamera逻辑中,调用stopRecording后立刻执行flipCamera和startRecording,会导致前一段录制的文件未完全生成就开始新录制,生成的片段文件损坏,后续合并失败。视频变换未适配摄像头方向
前置和后置摄像头录制的视频原生变换不同:前置摄像头视频是镜像状态,后置是正常方向。合并时统一使用固定旋转变换,导致切换摄像头后的片段画面错位、轨道不兼容,最终导出失败。Session配置修改不规范
flipCamera中修改session输入时,未使用beginConfiguration()和commitConfiguration()包裹,录制状态下直接修改session会导致session状态不稳定,影响录制完整性。
修复方案
1. 等待录制完成后再切换摄像头
新增状态标记,利用录制完成回调执行后续操作:
@Published var isSwitchingCamera: Bool = false func switchCamera() { guard isRecording else { flipCamera() return } isSwitchingCamera = true stopRecording(shouldToggle: false) } func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { if let error = error { print(error.localizedDescription) isSwitchingCamera = false return } self.recordedURLs.append(outputFileURL) // 切换摄像头场景:完成后启动新录制 if isSwitchingCamera { flipCamera() startRecording() isSwitchingCamera = false } else if self.recordedURLs.count == 1 { self.previewURL = outputFileURL } else { // 原有合并逻辑... let assets = recordedURLs.compactMap { AVURLAsset(url: $0) } self.previewURL = nil Task { await mergeVideos(assets: assets) { exporter in exporter.exportAsynchronously { if exporter.status == .failed{ print(exporter.error!) } else{ if let finalURL = exporter.outputURL{ DispatchQueue.main.async { self.previewURL = finalURL } } } } } } } }
2. 为每个片段适配对应变换
在合并时为每个片段单独设置变换,适配摄像头方向:
func mergeVideos(assets: [AVURLAsset],completion: @escaping (_ exporter: AVAssetExportSession)->()) async { let compostion = AVMutableComposition() var lastTime: CMTime = .zero guard let videoTrack = compostion.addMutableTrack(withMediaType: .video, preferredTrackID: Int32(kC
相关产品推荐
相关产品推荐

