- StreamRecorderKit: 跨平台库 - StreamRecorderEngine: 协议+类型 - HLSVariantResolver: HLS master 解析 - FFmpegRecorder: macOS (Process+ffmpeg) - AVFoundationRecorder: iOS (AVAssetReader+Writer) - RecorderFactory: 平台工厂 - StreamRecorder: CLI 入口 - 15秒录制测试通过
304 lines
10 KiB
Swift
304 lines
10 KiB
Swift
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
|