import Foundation import AVFoundation #if os(iOS) public final class AVFoundationRecorder: StreamRecorderEngine { public weak var delegate: StreamRecorderDelegate? public private(set) var isRecording = false private var assetReader: AVAssetReader? private var assetWriter: AVAssetWriter? private var videoOutput: AVAssetReaderVideoCompositionOutput? private var audioOutput: AVAssetReaderAudioMixOutput? private var videoInput: AVAssetWriterInput? private var audioInput: AVAssetWriterInput? private var duration: Double = 0 private var startTime: Date? private var bytesWritten: Int64 = 0 public init() {} public func start(url: URL, outputPath: String, duration: Double) async throws { guard !isRecording else { return } self.duration = duration self.startTime = Date() self.bytesWritten = 0 // 解析 HLS 变体 let resolvedURL = HLSVariantResolver.resolve(url: url, maxWidth: 1280) ?? url let asset = AVURLAsset(url: resolvedURL, options: [ AVURLAssetPreferPreciseDurationAndTimingKey: true ]) // 加载轨道(带重试) let videoTracks: [AVAssetTrack] let audioTracks: [AVAssetTrack] do { let isPlayable = try await asset.load(.isPlayable) if !isPlayable { print("警告: 媒体可能无法正常播放") } } catch { throw RecordingError.unknown("无法加载媒体: \(error.localizedDescription)") } var retryCount = 0 var vTracks: [AVAssetTrack] = [] var aTracks: [AVAssetTrack] = [] while vTracks.isEmpty && aTracks.isEmpty && retryCount < 10 { do { vTracks = try await asset.loadTracks(withMediaType: .video) aTracks = try await asset.loadTracks(withMediaType: .audio) } catch { throw RecordingError.unknown("无法加载轨道: \(error.localizedDescription)") } if vTracks.isEmpty && aTracks.isEmpty { retryCount += 1 try? await Task.sleep(nanoseconds: 1_000_000_000) } } videoTracks = vTracks audioTracks = aTracks if videoTracks.isEmpty && audioTracks.isEmpty { throw RecordingError.noMediaTracks } // 设置 reader let reader = try AVAssetReader(asset: asset) self.assetReader = reader // 设置 writer let outputURL = URL(fileURLWithPath: outputPath) if FileManager.default.fileExists(atPath: outputPath) { try FileManager.default.removeItem(atPath: outputPath) } let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) self.assetWriter = writer // 配置视频 if let videoTrack = videoTracks.first { let naturalSize = (try? await videoTrack.load(.naturalSize)) ?? CGSize(width: 1920, height: 1080) let outputSettings: [String: Any] = [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: naturalSize.width, AVVideoHeightKey: naturalSize.height ] let readerVideoOutput = AVAssetReaderVideoCompositionOutput( videoTracks: [videoTrack], videoSettings: [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] ) readerVideoOutput.alwaysCopiesSampleData = false if reader.canAdd(readerVideoOutput) { reader.add(readerVideoOutput) videoOutput = readerVideoOutput } let writerVideoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) writerVideoInput.expectsMediaDataInRealTime = false if writer.canAdd(writerVideoInput) { writer.add(writerVideoInput) videoInput = writerVideoInput } } // 配置音频 if let audioTrack = audioTracks.first { let readerAudioOutput = AVAssetReaderAudioMixOutput( audioTracks: [audioTrack], audioSettings: nil ) readerAudioOutput.alwaysCopiesSampleData = false if reader.canAdd(readerAudioOutput) { reader.add(readerAudioOutput) audioOutput = readerAudioOutput } let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128000 ] let writerAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) writerAudioInput.expectsMediaDataInRealTime = false if writer.canAdd(writerAudioInput) { writer.add(writerAudioInput) audioInput = writerAudioInput } } // 开始录制 reader.startReading() writer.startWriting() writer.startSession(atSourceTime: .zero) isRecording = true delegate?.recorderDidStart(self) // 处理视频 if let vOutput = videoOutput, let vInput = videoInput { processMedia(output: vOutput, input: vInput) } // 处理音频 if let aOutput = audioOutput, let aInput = audioInput { processMedia(output: aOutput, input: aInput) } // 等待完成或超时 while isRecording { try? await Task.sleep(nanoseconds: 500_000_000) let elapsed = Date().timeIntervalSince(startTime!) let progress = RecordingProgress( currentTime: elapsed, totalDuration: duration, bytesWritten: bytesWritten, bitrate: 0 ) delegate?.recorder(self, didUpdateProgress: progress) if elapsed >= duration { isRecording = false break } if reader.status == .failed { throw RecordingError.unknown("读取失败: \(reader.error?.localizedDescription ?? "未知错误")") } if writer.status == .failed { throw RecordingError.unknown("写入失败: \(writer.error?.localizedDescription ?? "未知错误")") } if reader.status == .completed { isRecording = false break } } // 完成 reader.cancelReading() videoInput?.markAsFinished() audioInput?.markAsFinished() await withCheckedContinuation { continuation in writer.finishWriting { continuation.resume() } } guard writer.status == .completed else { throw RecordingError.unknown("录制失败: \(writer.error?.localizedDescription ?? "未知错误")") } // 分析结果 let result = try await analyzeOutput(path: outputPath) delegate?.recorder(self, didFinishWithResult: result) } public func stop() { isRecording = false assetReader?.cancelReading() assetWriter?.cancelWriting() } // MARK: - Private private func processMedia(output: AVAssetReaderOutput, input: AVAssetWriterInput) { let queue = DispatchQueue(label: "recorder.media") input.requestMediaDataWhenReady(on: queue) { [weak self] in guard let self = self, self.isRecording else { input.markAsFinished() return } while input.isReadyForMoreMediaData { guard let sampleBuffer = output.copyNextSampleBuffer() else { input.markAsFinished() return } let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) let seconds = CMTimeGetSeconds(timestamp) if seconds >= self.duration { input.markAsFinished() self.isRecording = false return } input.append(sampleBuffer) // 估算字节数 let dataSize = CMSampleBufferGetTotalSampleSize(sampleBuffer) self.bytesWritten += Int64(dataSize) } } } private func analyzeOutput(path: String) async throws -> RecordingResult { let attrs = try? FileManager.default.attributesOfItem(atPath: path) let fileSize = attrs?[.size] as? Int64 ?? 0 let asset = AVURLAsset(url: URL(fileURLWithPath: path)) let videoTracks = try await asset.loadTracks(withMediaType: .video) let audioTracks = try await asset.loadTracks(withMediaType: .audio) var videoInfo: VideoInfo? var audioInfo: AudioInfo? if let videoTrack = videoTracks.first { let naturalSize = (try? await videoTrack.load(.naturalSize)) ?? .zero let fps = (try? await videoTrack.load(.nominalFrameRate)) ?? 30.0 videoInfo = VideoInfo( codec: "h264", width: Int(naturalSize.width), height: Int(naturalSize.height), fps: Double(fps), pixelFormat: "yuv420p" ) } if let audioTrack = audioTracks.first { let sampleRate = (try? await audioTrack.load(.naturalTimeScale)) ?? 44100 audioInfo = AudioInfo( codec: "aac", sampleRate: Int(sampleRate), channels: 2 ) } let duration = (try? await asset.load(.duration).seconds) ?? self.duration return RecordingResult( outputPath: path, duration: duration, fileSize: fileSize, hasVideo: !videoTracks.isEmpty, hasAudio: !audioTracks.isEmpty, videoInfo: videoInfo, audioInfo: audioInfo, audioLevel: nil ) } } #endif