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

@@ -33,6 +33,8 @@ class VideoRecorder {
throw Exception('当前没有可用的远端视频轨道,无法录制');
}
final path = await _buildOutputPath();
// ignore: avoid_print
print('[VideoRecorder] 录制开始,保存地址:$path');
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
await _recorder!.start(path, videoTrack: videoTracks.first);
_currentPath = path;
@@ -55,6 +57,8 @@ class VideoRecorder {
}
final p = _currentPath;
_currentPath = null;
// ignore: avoid_print
print('[VideoRecorder] 录制结束,已保存至:$p');
return p;
}

View File

@@ -47,8 +47,20 @@ web_rtc_controller_ios/
- 导航键:返回 / 主页 / 多任务Android keyCode 4 / 3 / 187
- 分辨率切换:原始画质 / 1080P / 720P / 480P
- 串流模式切换WebRTCSRTP 视频轨)/ 自编码DataChannel H.264 裸流 + VideoToolbox 硬解)
- 远端视频录制:连接后可将收到的画面录制为 MP4两种串流模式均支持
- 实时统计分辨率、帧率、码率、RTT、链路类型P2P/TURN、编解码器等
## 录制说明
控制面板顶部「录制 / 停止」按钮用于录制远端画面,行为对齐 Android 端 `VideoRecorder`
- **WebRTC 模式**`WebRTCClient` 把远端视频轨 fan-out 一份给 `VideoRecorder`(实现 `RTCVideoRenderer`
拿到解码后的 `CVPixelBuffer`,用 `AVAssetWriter` + H.264 硬编码封装为 MP4。
- **自编码模式**`SelfCodecDecoder` 用 VideoToolbox 硬解出 AVCC 帧后回传给 `VideoRecorder`
复用已解码的 H.264 帧写盘。
- 录制文件保存在 App 沙盒 `Documents/WebRTCRecordings/rec_YYYYMMDD_HHmmss.mp4`,停止后弹出保存路径提示。
- 断开连接时若仍在录制会自动取消(丢弃未保存内容)。
## 运行
1. 用 Xcode 打开 `web_rtc_controller_ios.xcodeproj`,首次打开等待 SPM 解析 WebRTC 依赖。

View File

@@ -23,6 +23,7 @@
FB000000000000000000000E /* DeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000E /* DeviceUtils.swift */; };
FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; };
FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; };
FB0000000000000000000012 /* VideoRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000012 /* VideoRecorder.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -43,6 +44,7 @@
FA000000000000000000000F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
FA0000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
FA0000000000000000000011 /* web_rtc_controller_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = web_rtc_controller_ios.app; sourceTree = BUILT_PRODUCTS_DIR; };
FA0000000000000000000012 /* VideoRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRecorder.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -120,6 +122,7 @@
children = (
FA0000000000000000000006 /* WebRTCClient.swift */,
FA0000000000000000000007 /* SelfCodecDecoder.swift */,
FA0000000000000000000012 /* VideoRecorder.swift */,
);
path = WebRTC;
sourceTree = "<group>";
@@ -183,7 +186,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
LastUpgradeCheck = 2630;
TargetAttributes = {
FD0000000000000000000002 = {
CreatedOnToolsVersion = 15.0;
@@ -235,6 +238,7 @@
FB0000000000000000000005 /* ControlMessage.swift in Sources */,
FB0000000000000000000006 /* WebRTCClient.swift in Sources */,
FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */,
FB0000000000000000000012 /* VideoRecorder.swift in Sources */,
FB0000000000000000000008 /* RemoteTouchView.swift in Sources */,
FB0000000000000000000009 /* RemoteVideoView.swift in Sources */,
FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */,
@@ -253,6 +257,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -306,6 +311,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -316,6 +322,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -362,6 +369,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
@@ -444,8 +452,8 @@
repositoryURL = "https://github.com/stasel/WebRTC.git";
requirement = {
kind = versionRange;
minimumVersion = 120.0.0;
maximumVersion = 200.0.0;
minimumVersion = 120.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,15 @@
{
"originHash" : "7ca243407a3b132834ec3b117a3aea85a8f743151ef868a872c8bbf8082921f6",
"pins" : [
{
"identity" : "webrtc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/stasel/WebRTC.git",
"state" : {
"revision" : "6ed87f05368632f71dc95c89c14c051561710925",
"version" : "150.0.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>web_rtc_controller_ios.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -19,6 +19,10 @@ final class SignalingClient: NSObject {
private var session: URLSession?
private var task: URLSessionWebSocketTask?
private var manuallyClosed = false
/// WebSocket didOpen true
private var isOpened = false
/// /
private var didNotifyClosure = false
init(serverUrl: String, deviceId: String) {
self.serverUrl = serverUrl
@@ -32,16 +36,20 @@ final class SignalingClient: NSObject {
return
}
manuallyClosed = false
isOpened = false
didNotifyClosure = false
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
task = session?.webSocketTask(with: url)
// didOpenWithProtocol
// socket receive Code 57
task?.resume()
receiveLoop()
}
func disconnect() {
manuallyClosed = true
isOpened = false
task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8))
task = nil
session?.invalidateAndCancel()
@@ -85,9 +93,31 @@ final class SignalingClient: NSObject {
}
self.receiveLoop()
case .failure(let error):
if !self.manuallyClosed {
self.notifyError(error.localizedDescription)
}
self.handleReceiveFailure(error)
}
}
}
/// """"
/// Socket Code 57
private func handleReceiveFailure(_ error: Error) {
guard !manuallyClosed, !didNotifyClosure else { return }
didNotifyClosure = true
let nsError = error as NSError
// NSPOSIXErrorDomain Code 57: Socket is not connected
let isDisconnect = (nsError.domain == NSPOSIXErrorDomain && nsError.code == 57)
|| (nsError.domain == NSURLErrorDomain
&& (nsError.code == NSURLErrorNetworkConnectionLost
|| nsError.code == NSURLErrorCancelled))
DispatchQueue.main.async {
if self.isOpened || isDisconnect {
// ""
self.delegate?.signalingDidDisconnect()
} else {
// ""
self.delegate?.signaling(didFail: error.localizedDescription)
}
}
}
@@ -106,6 +136,9 @@ extension SignalingClient: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?) {
isOpened = true
// socket receive
receiveLoop()
registerDevice()
DispatchQueue.main.async {
self.delegate?.signalingDidConnect()
@@ -116,7 +149,9 @@ extension SignalingClient: URLSessionWebSocketDelegate {
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?) {
guard !manuallyClosed else { return }
isOpened = false
guard !manuallyClosed, !didNotifyClosure else { return }
didNotifyClosure = true
DispatchQueue.main.async {
self.delegate?.signalingDidDisconnect()
}

View File

@@ -48,12 +48,14 @@ final class ControllerViewModel: NSObject, ObservableObject {
// MARK: - UI
@Published var serverUrl: String = "wss://www.ttstd.com/signal"
@Published var targetDeviceId: String = ""
@Published var targetDeviceId: String = "981964879"
@Published var statusText: String = "未连接"
/// Android setupPanel/controlPanel
@Published var isControlling: Bool = false
@Published var isConnecting: Bool = false
@Published var statsText: String = ""
///
@Published var statsExpanded: Bool = false
@Published var streamMode: StreamMode = .webrtc
@Published var selectedResolution: ResolutionOption = ResolutionOption.all[0]
/// supported_fps
@@ -65,6 +67,11 @@ final class ControllerViewModel: NSObject, ObservableObject {
@Published var showAuthSheet: Bool = false
@Published var alertMessage: String?
///
@Published var isRecording: Bool = false
///
@Published var lastRecordingPath: String?
let myDeviceId: String = DeviceUtils.deviceId()
// MARK: - UIKit ViewModel SwiftUI Representable
@@ -77,6 +84,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
private var signalingClient: SignalingClient?
private var webRTCClient: WebRTCClient?
private let selfCodecDecoder = SelfCodecDecoder()
/// MP4
private let videoRecorder = VideoRecorder()
private var statsTimer: Timer?
private var connectionStartTime: Date?
@@ -98,6 +107,24 @@ final class ControllerViewModel: NSObject, ObservableObject {
guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return }
self.videoAspect = CGFloat(w) / CGFloat(h)
}
// AVCC
selfCodecDecoder.onDecodedSampleBuffer = { [weak self] sample, isKey in
self?.videoRecorder.appendDecodedSampleBuffer(sample, isKey: isKey)
}
// /
videoRecorder.onStateChange = { [weak self] state in
self?.isRecording = (state == .recording)
}
videoRecorder.onSaved = { [weak self] path in
self?.lastRecordingPath = path
self?.alertMessage = "录制已保存:\n\(path)"
}
videoRecorder.onSavedEmpty = { [weak self] in
self?.alertMessage = "未录到任何画面"
}
videoRecorder.onError = { [weak self] msg in
self?.alertMessage = "录制失败: \(msg)"
}
}
// MARK: - /
@@ -134,6 +161,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
func disconnect() {
stopStatsTimer()
videoRecorder.cancel()
isRecording = false
selfCodecDecoder.setEnabled(false)
webRTCClient?.close()
webRTCClient = nil
@@ -225,6 +254,25 @@ final class ControllerViewModel: NSObject, ObservableObject {
selfCodecDecoder.setEnabled(mode == .selfCodec)
}
// MARK: -
/// WebRTC
///
func startRecording() {
guard isControlling else {
alertMessage = "请先连接被控设备"
return
}
guard !isRecording else { return }
videoRecorder.start()
}
/// MP4 Documents/WebRTCRecordings
func stopRecording() {
guard isRecording else { return }
videoRecorder.stop()
}
// MARK: -
fileprivate func startWebRTC() {
@@ -233,6 +281,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId)
client.delegate = self
client.selfCodecDecoder = selfCodecDecoder
client.extraRenderer = videoRecorder
webRTCClient = client
client.createOffer(
targetDeviceId: targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines),

View File

@@ -67,7 +67,7 @@ private struct SetupPanelView: View {
.padding(.trailing, 8)
}
Text(viewModel.isConnecting ? "连接中..." : "连接")
.fontWeight(.semibold)
.font(.system(size: 15, weight: .semibold))
Spacer()
}
}
@@ -101,84 +101,150 @@ private struct ControlPanelView: View {
var body: some View {
VStack(spacing: 0) {
statsPanel
topBar
videoArea
statsView
navButtons
}
.background(Color.black.ignoresSafeArea())
}
private var topBar: some View {
HStack(spacing: 12) {
Text(viewModel.statusText)
.font(.footnote)
.foregroundColor(.green)
.lineLimit(1)
Spacer()
// WebRTC /
Toggle(isOn: Binding(
get: { viewModel.streamMode == .selfCodec },
set: { viewModel.toggleStreamMode($0) })) {
Text("自编码")
.font(.footnote)
.foregroundColor(.white)
}
.toggleStyle(.switch)
.fixedSize()
//
Menu {
ForEach(ResolutionOption.all) { option in
Button {
viewModel.selectResolution(option)
} label: {
if option == viewModel.selectedResolution {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
/// /
private var statsPanel: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.statsExpanded.toggle()
}
} label: {
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
.font(.footnote)
}
// REPORT_RESOLUTION
Menu {
ForEach(viewModel.fpsOptions, id: \.self) { fps in
Button {
viewModel.selectFps(fps)
} label: {
if fps == viewModel.currentFps {
Label("\(fps)fps", systemImage: "checkmark")
} else {
Text("\(fps)fps")
}
}
HStack(spacing: 8) {
Image(systemName: "chart.bar.xaxis")
.font(.system(size: 11))
Text(statsSummary)
.font(.system(size: 11, design: .monospaced))
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Image(systemName: viewModel.statsExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .semibold))
}
} label: {
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
systemImage: "speedometer")
.font(.footnote)
.foregroundColor(Color(white: 0.8))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button(role: .destructive) {
viewModel.disconnect()
} label: {
Text("断开")
.font(.footnote)
.fontWeight(.semibold)
if viewModel.statsExpanded {
Text(viewModel.statsText.isEmpty ? "暂无统计数据" : viewModel.statsText)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(white: 0.75))
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 6)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(white: 0.08))
}
///
private var statsSummary: String {
if let first = viewModel.statsText.split(separator: "\n").first {
return String(first)
}
return "统计信息"
}
private var topBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
Text(viewModel.statusText)
.font(.footnote)
.foregroundColor(.green)
.lineLimit(1)
.fixedSize()
// WebRTC /
Toggle(isOn: Binding(
get: { viewModel.streamMode == .selfCodec },
set: { viewModel.toggleStreamMode($0) })) {
Text("自编码")
.font(.footnote)
.foregroundColor(.white)
}
.toggleStyle(.switch)
.fixedSize()
//
Menu {
ForEach(ResolutionOption.all) { option in
Button {
viewModel.selectResolution(option)
} label: {
if option == viewModel.selectedResolution {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
} label: {
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
.font(.footnote)
.fixedSize()
}
// REPORT_RESOLUTION
Menu {
ForEach(viewModel.fpsOptions, id: \.self) { fps in
Button {
viewModel.selectFps(fps)
} label: {
if fps == viewModel.currentFps {
Label("\(fps)fps", systemImage: "checkmark")
} else {
Text("\(fps)fps")
}
}
}
} label: {
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
systemImage: "speedometer")
.font(.footnote)
.fixedSize()
}
// MP4
Button {
if viewModel.isRecording {
viewModel.stopRecording()
} else {
viewModel.startRecording()
}
} label: {
Label(viewModel.isRecording ? "停止" : "录制",
systemImage: viewModel.isRecording ? "stop.circle.fill" : "circle.circle")
.font(Font.footnote.weight(.semibold))
.fixedSize()
}
.buttonStyle(.borderedProminent)
.tint(viewModel.isRecording ? .red : .orange)
.controlSize(.small)
Button(role: .destructive) {
viewModel.disconnect()
} label: {
Text("断开")
.font(Font.footnote.weight(.semibold))
.fixedSize()
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.background(Color(white: 0.1))
}
@@ -205,23 +271,30 @@ private struct ControlPanelView: View {
}
.aspectRatio(viewModel.videoAspect, contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
if viewModel.isRecording {
VStack {
HStack(spacing: 6) {
Circle()
.fill(Color.red)
.frame(width: 10, height: 10)
Text("REC")
.font(Font.caption2.weight(.bold))
.foregroundColor(.white)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.5))
.cornerRadius(6)
Spacer()
}
.padding(8)
}
}
}
}
private var statsView: some View {
HStack {
Text(viewModel.statsText)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(white: 0.75))
.multilineTextAlignment(.leading)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color(white: 0.08))
}
private var navButtons: some View {
HStack(spacing: 24) {
navButton(title: "返回", systemImage: "arrow.uturn.backward") {

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)
}
}
}
}