StreamRecorder/Sources/StreamRecorderKit/AVFoundationRecorder.swift
yumoqing 71d321b056 refactor: 跨平台架构 (macOS+iOS)
- StreamRecorderKit: 跨平台库
  - StreamRecorderEngine: 协议+类型
  - HLSVariantResolver: HLS master 解析
  - FFmpegRecorder: macOS (Process+ffmpeg)
  - AVFoundationRecorder: iOS (AVAssetReader+Writer)
  - RecorderFactory: 平台工厂
- StreamRecorder: CLI 入口
- 15秒录制测试通过
2026-06-28 17:49:12 +08:00

304 lines
10 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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