feat(ios): 添加远端视频录制功能并优化信令连接稳定性

- 新增 VideoRecorder 组件,支持 WebRTC 和自编码两种模式录制远端视频为 MP4
- 优化 SignalingClient 连接管理,修复 WebSocket 握手前 receive 导致的断开问题
- 重构控制面板 UI,支持统计信息折叠展开和录制控制按钮
- 添加录制状态指示、保存路径提示及断开自动取消录制逻辑
- 更新项目配置和文档,补充录制功能说明
This commit is contained in:
2026-08-01 03:59:43 +08:00
parent 30f26f27c3
commit a73f61f366
13 changed files with 651 additions and 84 deletions

View File

@@ -20,6 +20,8 @@ final class SelfCodecDecoder {
weak var displayLayer: AVSampleBufferDisplayLayer?
/// 线
var onResolutionUpdate: ((Int, Int) -> Void)?
/// AVCC CMSampleBuffer VideoRecorder
var onDecodedSampleBuffer: ((CMSampleBuffer, Bool) -> Void)?
private(set) var videoWidth = 0
private(set) var videoHeight = 0
@@ -268,6 +270,9 @@ final class SelfCodecDecoder {
layer.flush()
}
layer.enqueue(sampleBuffer)
// AVCC
onDecodedSampleBuffer?(sampleBuffer, isKey)
}
// MARK: - Annex-B

View File

@@ -0,0 +1,337 @@
import Foundation
import AVFoundation
import CoreMedia
import WebRTC
/// MP4 App Documents/WebRTCRecordings
///
/// Android `VideoRecorder`
/// - WebRTC `RTCVideoRenderer` `WebRTCClient` fan-out
/// `CVPixelBuffer` `AVAssetWriter` + H.264 MP4
/// - `SelfCodecDecoder` `CMSampleBuffer`AVCC
/// `appendDecodedSampleBuffer(_:isKey:)` H.264
///
/// WebRTC /线
final class VideoRecorder: NSObject {
enum RecorderError: Error {
case alreadyRecording
case notRecording
case noTrackYet
case writerInitFailed(String)
}
enum RecorderState {
case idle
case recording
}
/// /线
var onStateChange: ((RecorderState) -> Void)?
/// savedPath
var onSaved: ((String) -> Void)?
///
var onSavedEmpty: (() -> Void)?
/// AVAssetWriter
var onError: ((String) -> Void)?
private(set) var isRecording = false
// 访
private let recordQueue = DispatchQueue(label: "com.ttstd.video.recorder")
private var assetWriter: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var outputURL: URL?
private var startedTime: CMTime?
private var framesWritten = 0
// MARK: -
/// WebRTC track
/// appendDecodedSampleBuffer
/// true false
@discardableResult
func start() -> Bool {
if isRecording { return false }
isRecording = true
DispatchQueue.main.async { self.onStateChange?(.recording) }
return true
}
///
func stop() {
guard isRecording else { return }
isRecording = false
recordQueue.async { [weak self] in
self?.finalizeLocked()
}
}
/// /
func cancel() {
guard isRecording else { return }
isRecording = false
recordQueue.async { [weak self] in
self?.teardownLocked()
}
}
// MARK: - WebRTC RTCVideoRenderer
/// WebRTCClient add renderer
/// ensureWriter
func handleWebRTCFrame(_ frame: RTCVideoFrame) {
guard isRecording else { return }
// buffer RTCVideoFrameBuffer RTCCVPixelBuffer pixelBuffer
guard let cvBuffer = frame.buffer as? RTCCVPixelBuffer else { return }
let width = Int(cvBuffer.width)
let height = Int(cvBuffer.height)
guard width > 0, height > 0 else { return }
let pts = CMTime(value: frame.timeStampNs, timescale: 1_000_000_000)
recordQueue.async { [weak self] in
guard let self else { return }
self.appendPixelBuffer(cvBuffer.pixelBuffer, width: width, height: height, pts: pts)
}
}
// MARK: -
/// SelfCodecDecoder
/// sampleBuffer AVAssetWriter H.264
func appendDecodedSampleBuffer(_ sample: CMSampleBuffer, isKey: Bool) {
guard isRecording else { return }
recordQueue.async { [weak self] in
guard let self else { return }
self.appendSampleBuffer(sample, isKey)
}
}
// MARK: -
/// CVPixelBuffer CMSampleBufferWebRTC
private func makeSampleBuffer(from pixelBuffer: CVPixelBuffer, pts: CMTime) -> CMSampleBuffer? {
var formatDescription: CMVideoFormatDescription?
guard CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription) == noErr,
let formatDescription else { return nil }
var sampleBuffer: CMSampleBuffer?
var timing = CMSampleTimingInfo(
duration: CMTime.invalid,
presentationTimeStamp: pts,
decodeTimeStamp: CMTime.invalid)
let status = CMSampleBufferCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
dataReady: true,
makeDataReadyCallback: nil,
refcon: nil,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer)
guard status == noErr else { return nil }
return sampleBuffer
}
private func appendPixelBuffer(_ pixelBuffer: CVPixelBuffer,
width: Int, height: Int, pts: CMTime) {
ensureWriterForPixelBuffer(width: width, height: height)
guard let input = videoInput, input.isReadyForMoreMediaData else { return }
var finalPts = pts
if let start = startedTime {
finalPts = CMTimeSubtract(pts, start)
} else {
startedTime = pts
finalPts = .zero
}
guard let sample = makeSampleBuffer(from: pixelBuffer, pts: finalPts) else { return }
let success = input.append(sample)
if success { framesWritten += 1 }
}
private func appendSampleBuffer(_ sample: CMSampleBuffer, _ isKey: Bool = false) {
ensureWriterForSampleBuffer(sample)
guard let input = videoInput, input.isReadyForMoreMediaData else { return }
if startedTime == nil {
startedTime = CMSampleBufferGetPresentationTimeStamp(sample)
}
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
let finalPts = CMTimeSubtract(pts, startedTime ?? pts)
// CMSampleBuffer
guard let shifted = makeShiftedSampleBuffer(from: sample, pts: finalPts) else { return }
let success = input.append(shifted)
if success { framesWritten += 1 }
}
/// sampleBuffer presentation/decode pts
private func makeShiftedSampleBuffer(from sample: CMSampleBuffer, pts: CMTime) -> CMSampleBuffer? {
var newSample: CMSampleBuffer?
var timing = CMSampleTimingInfo(
duration: CMTime.invalid,
presentationTimeStamp: pts,
decodeTimeStamp: CMTime.invalid)
let status = CMSampleBufferCreateCopyWithNewTiming(
allocator: kCFAllocatorDefault,
sampleBuffer: sample,
sampleTimingEntryCount: 1,
sampleTimingArray: &timing,
sampleBufferOut: &newSample)
guard status == noErr else { return nil }
return newSample
}
/// WebRTC NV12/ARGB AVAssetWriter
private func ensureWriterForPixelBuffer(width: Int, height: Int) {
guard assetWriter == nil else { return }
guard let url = Self.buildOutputURL() else { return }
outputURL = url
do {
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: min(max(width * height * 2, 1_000_000), 6_000_000),
AVVideoMaxKeyFrameIntervalKey: 30
]
]
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
input.expectsMediaDataInRealTime = true
guard writer.canAdd(input) else {
DispatchQueue.main.async {
self.onError?("AVAssetWriter 不支持该视频配置")
}
self.teardownLocked()
return
}
writer.add(input)
guard writer.startWriting() else {
DispatchQueue.main.async {
self.onError?("AVAssetWriter 启动失败:\(writer.error?.localizedDescription ?? "未知错误")")
}
self.teardownLocked()
return
}
writer.startSession(atSourceTime: .zero)
assetWriter = writer
videoInput = input
} catch {
DispatchQueue.main.async { self.onError?("录制初始化失败: \(error.localizedDescription)") }
self.teardownLocked()
}
}
/// sampleBuffer
/// AVAssetWriterInput H.264
/// SPS/PPS
private func ensureWriterForSampleBuffer(_ sample: CMSampleBuffer) {
if assetWriter != nil { return }
guard let desc = CMSampleBufferGetFormatDescription(sample) else {
NSLog("[VideoRecorder] 无法从 sampleBuffer 获取格式描述")
return
}
let dims = CMVideoFormatDescriptionGetDimensions(desc)
ensureWriterForSampleBuffer(width: Int(dims.width), height: Int(dims.height))
}
private func ensureWriterForSampleBuffer(width: Int, height: Int) {
guard assetWriter == nil else { return }
guard let url = Self.buildOutputURL() else { return }
outputURL = url
do {
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: min(max(width * height * 2, 1_000_000), 6_000_000),
AVVideoMaxKeyFrameIntervalKey: 30
]
]
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
input.expectsMediaDataInRealTime = true
guard writer.canAdd(input) else {
DispatchQueue.main.async { self.onError?("AVAssetWriter 不支持该视频配置") }
self.teardownLocked()
return
}
writer.add(input)
guard writer.startWriting() else {
DispatchQueue.main.async {
self.onError?("AVAssetWriter 启动失败:\(writer.error?.localizedDescription ?? "未知错误")")
}
self.teardownLocked()
return
}
writer.startSession(atSourceTime: .zero)
assetWriter = writer
videoInput = input
} catch {
DispatchQueue.main.async { self.onError?("录制初始化失败: \(error.localizedDescription)") }
self.teardownLocked()
}
}
private func finalizeLocked() {
let written = framesWritten
let url = outputURL
videoInput?.markAsFinished()
assetWriter?.finishWriting { [weak self] in
guard let self else { return }
self.teardownLocked()
DispatchQueue.main.async {
self.onStateChange?(.idle)
if written > 0, let url {
self.onSaved?(url.path)
} else {
self.onSavedEmpty?()
}
}
}
}
private func teardownLocked() {
assetWriter = nil
videoInput = nil
outputURL = nil
startedTime = nil
framesWritten = 0
}
// MARK: -
private static func buildOutputURL() -> URL? {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let dir = documents.appendingPathComponent("WebRTCRecordings", isDirectory: true)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
} catch {
NSLog("[VideoRecorder] 创建目录失败: \(error.localizedDescription)")
return nil
}
let df = DateFormatter()
df.dateFormat = "yyyyMMdd_HHmmss"
let name = "rec_\(df.string(from: Date())).mp4"
return dir.appendingPathComponent(name)
}
}
// MARK: - RTCVideoRendererWebRTC
extension VideoRecorder: RTCVideoRenderer {
func setSize(_ size: CGSize) {
// CVPixelBuffer
}
func renderFrame(_ frame: RTCVideoFrame?) {
guard let frame else { return }
handleWebRTCFrame(frame)
}
}

View File

@@ -46,6 +46,8 @@ final class WebRTCClient: NSObject {
private var videoChannel: RTCDataChannel?
private weak var remoteRenderer: RTCVideoRenderer?
private var remoteVideoTrack: RTCVideoTrack?
/// fan-out WebRTC
weak var extraRenderer: RTCVideoRenderer?
private var connected = false
init(signaling: SignalingClient, myDeviceId: String) {
@@ -208,6 +210,9 @@ final class WebRTCClient: NSObject {
if let track = remoteVideoTrack, let renderer = remoteRenderer {
track.remove(renderer)
}
if let track = remoteVideoTrack, let extra = extraRenderer {
track.remove(extra)
}
remoteVideoTrack = nil
peerConnection?.close()
peerConnection = nil
@@ -289,6 +294,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
if let renderer = self.remoteRenderer {
track.add(renderer)
}
if let extra = self.extraRenderer {
track.add(extra)
}
}
}
}