You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 11:37:10