- 新增 VideoRecorder 组件,支持 WebRTC 和自编码两种模式录制远端视频为 MP4 - 优化 SignalingClient 连接管理,修复 WebSocket 握手前 receive 导致的断开问题 - 重构控制面板 UI,支持统计信息折叠展开和录制控制按钮 - 添加录制状态指示、保存路径提示及断开自动取消录制逻辑 - 更新项目配置和文档,补充录制功能说明
349 lines
12 KiB
Swift
349 lines
12 KiB
Swift
import Foundation
|
||
import AVFoundation
|
||
import CoreMedia
|
||
|
||
/// “自编码”串流模式解码器:
|
||
/// 从 video_channel DataChannel 接收被控端 MediaCodec 编码的 H.264 裸流,
|
||
/// 重组分片 -> 解析单元 -> 转换为 AVCC 格式 -> 通过 AVSampleBufferDisplayLayer 硬解渲染。
|
||
///
|
||
/// 二进制协议(与被控端 SelfCodecEncoder 保持一致,均为大端序):
|
||
/// 分片: MAGIC(0xAB,1B) + seq(int32) + total(int16) + idx(int16) + len(int32) + payload
|
||
/// 单元: type(1B: 1=CONFIG 2=FRAME) + pts(uint32,ms) + isKey(1B) + len(int32) + H.264 Annex-B 数据
|
||
final class SelfCodecDecoder {
|
||
|
||
// ---- 必须与被控端 SelfCodecEncoder 保持一致 ----
|
||
private static let magic: UInt8 = 0xAB
|
||
private static let unitTypeConfig: UInt8 = 1
|
||
private static let unitTypeFrame: UInt8 = 2
|
||
|
||
/// 渲染图层(由 SelfCodecDisplayView 提供)
|
||
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
|
||
|
||
private let queue = DispatchQueue(label: "com.ttstd.selfcodec.decoder")
|
||
private var enabled = false
|
||
|
||
// 分片重组
|
||
private final class ChunkBuffer {
|
||
var total = 0
|
||
var received = 0
|
||
var chunks: [Data?] = []
|
||
}
|
||
private var assembling: [Int32: ChunkBuffer] = [:]
|
||
private var lastSeq: Int32 = -1
|
||
|
||
// H.264 参数集与格式
|
||
private var sps: Data?
|
||
private var pps: Data?
|
||
private var formatDescription: CMVideoFormatDescription?
|
||
|
||
// MARK: - 开关
|
||
|
||
func setEnabled(_ on: Bool) {
|
||
queue.async {
|
||
self.enabled = on
|
||
if !on {
|
||
self.resetLocked()
|
||
}
|
||
}
|
||
}
|
||
|
||
func release() {
|
||
queue.async {
|
||
self.enabled = false
|
||
self.resetLocked()
|
||
}
|
||
}
|
||
|
||
private func resetLocked() {
|
||
assembling.removeAll()
|
||
lastSeq = -1
|
||
sps = nil
|
||
pps = nil
|
||
formatDescription = nil
|
||
videoWidth = 0
|
||
videoHeight = 0
|
||
displayLayer?.flushAndRemoveImage()
|
||
}
|
||
|
||
// MARK: - 入口:DataChannel 二进制消息
|
||
|
||
func onBinaryMessage(_ raw: Data) {
|
||
queue.async {
|
||
self.handleChunk(raw)
|
||
}
|
||
}
|
||
|
||
private func handleChunk(_ raw: Data) {
|
||
guard enabled, raw.count >= 13 else { return }
|
||
var r = BigEndianReader(data: raw)
|
||
guard r.readByte() == Self.magic else { return }
|
||
guard let seq = r.readInt32() else { return }
|
||
|
||
// 过期检查:同一单元的所有分片 seq 相同,只丢弃明显更旧的单元
|
||
if lastSeq != -1 && seq < lastSeq && (lastSeq - seq) < 1000 {
|
||
return
|
||
}
|
||
guard let total = r.readInt16(), let idx = r.readInt16(),
|
||
let len = r.readInt32(), let chunk = r.readData(Int(len)),
|
||
total > 0 else { return }
|
||
|
||
let cb: ChunkBuffer
|
||
if let existing = assembling[seq] {
|
||
cb = existing
|
||
} else {
|
||
cb = ChunkBuffer()
|
||
cb.total = Int(total)
|
||
cb.chunks = Array(repeating: nil, count: Int(total))
|
||
assembling[seq] = cb
|
||
if assembling.count > 24, let minKey = assembling.keys.min() {
|
||
assembling.removeValue(forKey: minKey)
|
||
}
|
||
}
|
||
|
||
let i = Int(idx)
|
||
if i >= 0 && i < cb.total && cb.chunks[i] == nil {
|
||
cb.chunks[i] = chunk
|
||
cb.received += 1
|
||
}
|
||
if cb.received == cb.total {
|
||
assembling.removeValue(forKey: seq)
|
||
if seq > lastSeq { lastSeq = seq }
|
||
var unit = Data()
|
||
for c in cb.chunks where c != nil { unit.append(c!) }
|
||
handleUnit(unit)
|
||
}
|
||
}
|
||
|
||
// MARK: - 单元处理
|
||
|
||
private func handleUnit(_ unit: Data) {
|
||
var r = BigEndianReader(data: unit)
|
||
guard let type = r.readByte(),
|
||
let ptsRaw = r.readInt32(),
|
||
let isKey = r.readByte(),
|
||
let len = r.readInt32(),
|
||
let data = r.readData(Int(len)) else { return }
|
||
let ptsMs = Int64(UInt32(bitPattern: ptsRaw))
|
||
|
||
if type == Self.unitTypeConfig {
|
||
handleConfig(data)
|
||
} else if type == Self.unitTypeFrame {
|
||
handleFrame(data, ptsMs: ptsMs, isKey: isKey != 0)
|
||
}
|
||
}
|
||
|
||
/// CONFIG 单元:MediaCodec 的 csd-0(SPS)/csd-1(PPS),Annex-B 格式
|
||
private func handleConfig(_ data: Data) {
|
||
for nal in Self.annexBNalUnits(in: data) {
|
||
guard let first = nal.first else { continue }
|
||
switch first & 0x1F {
|
||
case 7: if sps != nal { sps = nal; formatDescription = nil }
|
||
case 8: if pps != nal { pps = nal; formatDescription = nil }
|
||
default: break
|
||
}
|
||
}
|
||
rebuildFormatDescriptionIfNeeded()
|
||
}
|
||
|
||
private func rebuildFormatDescriptionIfNeeded() {
|
||
guard formatDescription == nil, let sps, let pps else { return }
|
||
var desc: CMVideoFormatDescription?
|
||
let status = sps.withUnsafeBytes { spsPtr -> OSStatus in
|
||
pps.withUnsafeBytes { ppsPtr -> OSStatus in
|
||
let paramSets: [UnsafePointer<UInt8>] = [
|
||
spsPtr.bindMemory(to: UInt8.self).baseAddress!,
|
||
ppsPtr.bindMemory(to: UInt8.self).baseAddress!
|
||
]
|
||
let sizes: [Int] = [sps.count, pps.count]
|
||
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||
allocator: kCFAllocatorDefault,
|
||
parameterSetCount: 2,
|
||
parameterSetPointers: paramSets,
|
||
parameterSetSizes: sizes,
|
||
nalUnitHeaderLength: 4,
|
||
formatDescriptionOut: &desc)
|
||
}
|
||
}
|
||
guard status == noErr, let desc else {
|
||
NSLog("[SelfCodec] create format description failed: \(status)")
|
||
return
|
||
}
|
||
formatDescription = desc
|
||
displayLayer?.flush()
|
||
|
||
let dims = CMVideoFormatDescriptionGetDimensions(desc)
|
||
let w = Int(dims.width), h = Int(dims.height)
|
||
if w != videoWidth || h != videoHeight {
|
||
videoWidth = w
|
||
videoHeight = h
|
||
DispatchQueue.main.async {
|
||
self.onResolutionUpdate?(w, h)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// FRAME 单元:Annex-B -> AVCC(4 字节大端长度前缀),封装 CMSampleBuffer 后送显
|
||
private func handleFrame(_ data: Data, ptsMs: Int64, isKey: Bool) {
|
||
// 帧内可能携带 SPS/PPS(部分编码器关键帧前重发参数集)
|
||
var vclData = Data()
|
||
for nal in Self.annexBNalUnits(in: data) {
|
||
guard let first = nal.first else { continue }
|
||
let nalType = first & 0x1F
|
||
if nalType == 7 {
|
||
if sps != nal { sps = nal; formatDescription = nil }
|
||
continue
|
||
}
|
||
if nalType == 8 {
|
||
if pps != nal { pps = nal; formatDescription = nil }
|
||
continue
|
||
}
|
||
var lenBE = UInt32(nal.count).bigEndian
|
||
withUnsafeBytes(of: &lenBE) { vclData.append(contentsOf: $0) }
|
||
vclData.append(nal)
|
||
}
|
||
rebuildFormatDescriptionIfNeeded()
|
||
guard enabled, !vclData.isEmpty, let formatDescription, let layer = displayLayer else { return }
|
||
|
||
// 构造 CMBlockBuffer
|
||
var blockBuffer: CMBlockBuffer?
|
||
var status = CMBlockBufferCreateWithMemoryBlock(
|
||
allocator: kCFAllocatorDefault,
|
||
memoryBlock: nil,
|
||
blockLength: vclData.count,
|
||
blockAllocator: kCFAllocatorDefault,
|
||
customBlockSource: nil,
|
||
offsetToData: 0,
|
||
dataLength: vclData.count,
|
||
flags: 0,
|
||
blockBufferOut: &blockBuffer)
|
||
guard status == kCMBlockBufferNoErr, let blockBuffer else { return }
|
||
status = vclData.withUnsafeBytes { ptr in
|
||
CMBlockBufferReplaceDataBytes(
|
||
with: ptr.baseAddress!,
|
||
blockBuffer: blockBuffer,
|
||
offsetIntoDestination: 0,
|
||
dataLength: vclData.count)
|
||
}
|
||
guard status == kCMBlockBufferNoErr else { return }
|
||
|
||
// 构造 CMSampleBuffer(立即显示,不依赖时间轴)
|
||
var sampleBuffer: CMSampleBuffer?
|
||
var timing = CMSampleTimingInfo(
|
||
duration: .invalid,
|
||
presentationTimeStamp: CMTime(value: ptsMs, timescale: 1000),
|
||
decodeTimeStamp: .invalid)
|
||
var sampleSize = vclData.count
|
||
status = CMSampleBufferCreateReady(
|
||
allocator: kCFAllocatorDefault,
|
||
dataBuffer: blockBuffer,
|
||
formatDescription: formatDescription,
|
||
sampleCount: 1,
|
||
sampleTimingEntryCount: 1,
|
||
sampleTimingArray: &timing,
|
||
sampleSizeEntryCount: 1,
|
||
sampleSizeArray: &sampleSize,
|
||
sampleBufferOut: &sampleBuffer)
|
||
guard status == noErr, let sampleBuffer else { return }
|
||
|
||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true) as? [CFMutableDictionary],
|
||
let dict = attachments.first {
|
||
CFDictionarySetValue(
|
||
dict,
|
||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||
if !isKey {
|
||
CFDictionarySetValue(
|
||
dict,
|
||
Unmanaged.passUnretained(kCMSampleAttachmentKey_NotSync).toOpaque(),
|
||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||
}
|
||
}
|
||
|
||
if layer.status == .failed || layer.requiresFlushToResumeDecoding {
|
||
layer.flush()
|
||
}
|
||
layer.enqueue(sampleBuffer)
|
||
|
||
// 自编码模式录制:把解码后的 AVCC 帧回传给录制器写盘
|
||
onDecodedSampleBuffer?(sampleBuffer, isKey)
|
||
}
|
||
|
||
// MARK: - Annex-B 拆分
|
||
|
||
/// 拆分 Annex-B 码流(00 00 01 / 00 00 00 01 起始码)为 NAL 单元数组(不含起始码)
|
||
static func annexBNalUnits(in data: Data) -> [Data] {
|
||
var result: [Data] = []
|
||
let bytes = [UInt8](data)
|
||
let count = bytes.count
|
||
var starts: [Int] = []
|
||
var i = 0
|
||
while i + 2 < count {
|
||
if bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 1 {
|
||
starts.append(i + 3)
|
||
i += 3
|
||
} else {
|
||
i += 1
|
||
}
|
||
}
|
||
guard !starts.isEmpty else { return data.isEmpty ? [] : [data] }
|
||
for (idx, start) in starts.enumerated() {
|
||
var end = count
|
||
if idx + 1 < starts.count {
|
||
end = starts[idx + 1] - 3
|
||
// 兼容 4 字节起始码(00 00 00 01)
|
||
if end > start && bytes[end - 1] == 0 { end -= 1 }
|
||
}
|
||
if end > start {
|
||
result.append(Data(bytes[start..<end]))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
|
||
// MARK: - 大端序读取器
|
||
|
||
private struct BigEndianReader {
|
||
let data: Data
|
||
private var offset: Int
|
||
|
||
init(data: Data) {
|
||
self.data = data
|
||
self.offset = data.startIndex
|
||
}
|
||
|
||
mutating func readByte() -> UInt8? {
|
||
guard offset < data.endIndex else { return nil }
|
||
defer { offset += 1 }
|
||
return data[offset]
|
||
}
|
||
|
||
mutating func readInt16() -> Int16? {
|
||
guard offset + 2 <= data.endIndex else { return nil }
|
||
let v = (UInt16(data[offset]) << 8) | UInt16(data[offset + 1])
|
||
offset += 2
|
||
return Int16(bitPattern: v)
|
||
}
|
||
|
||
mutating func readInt32() -> Int32? {
|
||
guard offset + 4 <= data.endIndex else { return nil }
|
||
var v: UInt32 = 0
|
||
for i in 0..<4 { v = (v << 8) | UInt32(data[offset + i]) }
|
||
offset += 4
|
||
return Int32(bitPattern: v)
|
||
}
|
||
|
||
mutating func readData(_ length: Int) -> Data? {
|
||
guard length >= 0, offset + length <= data.endIndex else { return nil }
|
||
defer { offset += length }
|
||
return data.subdata(in: offset..<(offset + length))
|
||
}
|
||
}
|