AVAssetExportSession.exportAsynchronously返回‘Operation Stopped’求助
问题:视频拼接时AVAssetExportSession返回‘Operation Stopped’
我尝试将拍摄并保存的多个视频文件拼接后导出至系统相册,通过遍历instructionURLs中的视频文件URL完成拼接,但调用AVAssetExportSession.exportAsynchronously()时返回‘Operation Stopped’。用户拍摄视频后点击按钮调用spliceVideos执行合并导出操作,相关代码见下方。
解决方案
以下是几个导致导出失败的常见原因及修复方案:
1. 导出目标文件已存在
AVAssetExportSession不允许覆盖已存在的文件,必须先删除目标路径的旧文件。
2. AVAsset未完成加载
直接创建AVAsset(url:)后立即访问tracks或duration,此时资源可能还没异步加载完成,导致合成数据异常。需要使用loadValuesAsynchronously(forKeys:)确保资源加载完成。
3. 导出预设兼容性问题
部分设备可能不支持AVAssetExportPresetLowQuality,建议使用更通用的预设,比如AVAssetExportPresetHighestQuality,或者先检查预设是否可用。
4. 视频轨道格式不匹配
多个视频的分辨率、帧率、编码格式不一致时,合成后导出可能失败,需要统一视频参数,或者使用AVMutableVideoComposition处理适配。
5. 导出会话被提前释放
AVAssetExportSession是局部变量,若没有强引用,可能在异步导出过程中被ARC释放,导致操作终止。需要将exporter设为类的属性,保持强引用。
修改后的完整代码
import SwiftUI import AVKit import AVFoundation import Photos class VideoRecorder: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate { var captureSession: AVCaptureSession? var previewLayer: AVCaptureVideoPreviewLayer? var videoFileOutput: AVCaptureMovieFileOutput? var instructionURLs: [URL] = [] var audioSamples: [CMSampleBuffer] = [] // 新增:保持导出会话的强引用 private var activeExporter: AVAssetExportSession? override init() { super.init() startCaptureSession() } func startCaptureSession() { guard UIImagePickerController.isSourceTypeAvailable(.camera) else { print("无法录制视频:相机不可用") return } captureSession = AVCaptureSession() captureSession?.sessionPreset = .high guard let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { print("无法录制视频:未找到后置摄像头") return } do { let input = try AVCaptureDeviceInput(device: backCamera) if captureSession!.canAddInput(input) { captureSession!.addInput(input) } let videoOutput = AVCaptureVideoDataOutput() videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue.global(qos: .default)) if captureSession!.canAddOutput(videoOutput) { captureSession!.addOutput(videoOutput) } let audioOutput = AVCaptureAudioDataOutput() audioOutput.setSampleBufferDelegate(self, queue: DispatchQueue.global(qos: .default)) if captureSession!.canAddOutput(audioOutput) { captureSession!.addOutput(audioOutput) } videoFileOutput = AVCaptureMovieFileOutput() if captureSession!.canAddOutput(videoFileOutput!) { captureSession!.addOutput(videoFileOutput!) } previewLayer = AVCaptureVideoPreviewLayer(session: captureSession!) previewLayer?.videoGravity = .resizeAspectFill captureSession!.startRunning() } catch { print("录制初始化错误:\(error.localizedDescription)") } } func stopCaptureSession() { captureSession?.stopRunning() } func startRecording() { guard let videoFileOutput = videoFileOutput else { return } let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let outputURL = documentsDirectory.appendingPathComponent("instruction\(instructionURLs.count).mov") videoFileOutput.startRecording(to: outputURL, recordingDelegate: self) } func stopRecording() { guard let videoFileOutput = videoFileOutput else { return } videoFileOutput.stopRecording() } func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { if let error = error { print("录制错误:\(error.localizedDescription)") } else { instructionURLs.append(outputFileURL) } } func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { if let audioDataOutput = output as? AVCaptureAudioDataOutput { handleAudioSampleBuffer(sampleBuffer) } else if let videoDataOutput = output as? AVCaptureVideoDataOutput { handleVideoSampleBuffer(sampleBuffer) } } func handleAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer) { audioSamples.append(sampleBuffer) } func handleVideoSampleBuffer(_ sampleBuffer: CMSampleBuffer) { // 暂时未处理视频帧 } func spliceVideos(completion: @escaping (URL?) -> Void) { let composition = AVMutableComposition() guard let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid), let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { completion(nil) return } var currentTime = CMTime.zero let group = DispatchGroup() // 逐个加载视频资源,确保资源准备完成 for instructionURL in instructionURLs { group.enter() let asset = AVAsset(url: instructionURL) let keysToLoad = ["tracks", "duration"] asset.loadValuesAsynchronously(forKeys: keysToLoad) { defer { group.leave() } var error: NSError? for key in keysToLoad { if asset.statusOfValue(forKey: key, error: &error) != .loaded { print("资源加载失败:\(error?.localizedDescription ?? "未知错误")") return } } do { guard let videoAssetTrack = asset.tracks(withMediaType: .video).first else { print("视频无有效轨道") return } try videoTrack.insertTimeRange(CMTimeRange(start: .zero, duration: asset.duration), of: videoAssetTrack, at: currentTime) if let audioAssetTrack = asset.tracks(withMediaType: .audio).first { try audioTrack.insertTimeRange(CMTimeRange(start: .zero, duration: asset.duration), of: audioAssetTrack, at: currentTime) } currentTime = CMTimeAdd(currentTime, asset.duration) } catch { print("插入轨道错误:\(error.localizedDescription)") } } } group.notify(queue: .main) { let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let outputURL = documentsDirectory.appendingPathComponent("finalVideo.mov") // 删除已存在的导出文件 if FileManager.default.fileExists(atPath: outputURL.path) { try? FileManager.default.removeItem(at: outputURL) } // 检查预设是否可用 guard AVAssetExportSession.allExportPresets().contains(AVAssetExportPresetHighestQuality) else { print("导出预设不可用") completion(nil) return } self.activeExporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) guard let exporter = self.activeExporter else { print("创建导出会话失败") completion(nil) return } exporter.outputURL = outputURL exporter.outputFileType = .mov exporter.exportAsynchronously { defer { self.activeExporter = nil } // 导出完成后释放引用 switch exporter.status { case .completed: completion(outputURL) case .failed, .cancelled: if let error = exporter.error { print("导出错误:\(error.localizedDescription)") } completion(nil) default: completion(nil) } } } } func saveVideoToPhotosLibrary(url: URL) { PHPhotoLibrary.requestAuthorization { status in guard status == .authorized else { print("相册权限被拒绝") return } PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) } completionHandler: { success, error in if let error = error { print("保存到相册失败:\(error.localizedDescription)") } else { print("视频已保存到相册") } } } } } struct ContentView: View { @StateObject private var videoRecorder = VideoRecorder() @State private var isRecording = false var body: some View { ZStack { if let previewLayer = videoRecorder.previewLayer { VideoPreviewView(previewLayer: previewLayer) } VStack { Spacer() Button(action: { if isRecording { videoRecorder.stopRecording() } else { videoRecorder.startRecording() } isRecording.toggle() }) { Text(isRecording ? "停止录制" : "开始录制") .font(.title) .padding() .background(Color.red) .foregroundColor(.white) .cornerRadius(10) } .padding() Button(action: { videoRecorder.spliceVideos { outputURL in if let url = outputURL { videoRecorder.saveVideoToPhotosLibrary(url: url) } } }) { Text("拼接视频") .font(.title) .padding() .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) } .padding() } } } } // 补充VideoPreviewView的实现 struct VideoPreviewView: UIViewRepresentable { let previewLayer: AVCaptureVideoPreviewLayer func makeUIView(context: Context) -> UIView { let view = UIView(frame: UIScreen.main.bounds) previewLayer.frame = view.bounds view.layer.addSublayer(previewLayer) return view } func updateUIView(_ uiView: UIView, context: Context) { previewLayer.frame = uiView.bounds } }
内容的提问来源于stack exchange,提问作者nickcoding2
相关产品推荐
相关产品推荐

