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

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) { }
}

异常原因分析
  1. 录制停止的异步性未处理
    AVCaptureMovieFileOutput.stopRecording()是异步操作,调用后不会立刻完成文件写入。当前switchCamera逻辑中,调用stopRecording后立刻执行flipCamera和startRecording,会导致前一段录制的文件未完全生成就开始新录制,生成的片段文件损坏,后续合并失败。

  2. 视频变换未适配摄像头方向
    前置和后置摄像头录制的视频原生变换不同:前置摄像头视频是镜像状态,后置是正常方向。合并时统一使用固定旋转变换,导致切换摄像头后的片段画面错位、轨道不兼容,最终导出失败。

  3. 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
相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.04 23:55:02