feat(ios): 添加远端视频录制功能并优化信令连接稳定性
- 新增 VideoRecorder 组件,支持 WebRTC 和自编码两种模式录制远端视频为 MP4 - 优化 SignalingClient 连接管理,修复 WebSocket 握手前 receive 导致的断开问题 - 重构控制面板 UI,支持统计信息折叠展开和录制控制按钮 - 添加录制状态指示、保存路径提示及断开自动取消录制逻辑 - 更新项目配置和文档,补充录制功能说明
This commit is contained in:
1 parent
30f26f27c3
commit
a73f61f366
13 files changed
+651
-84
No files matched your search
@@ -33,6 +33,8 @@ class VideoRecorder {
|
|||||||
throw Exception('当前没有可用的远端视频轨道,无法录制');
|
throw Exception('当前没有可用的远端视频轨道,无法录制');
|
||||||
}
|
}
|
||||||
final path = await _buildOutputPath();
|
final path = await _buildOutputPath();
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[VideoRecorder] 录制开始,保存地址:$path');
|
||||||
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
|
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
|
||||||
await _recorder!.start(path, videoTrack: videoTracks.first);
|
await _recorder!.start(path, videoTrack: videoTracks.first);
|
||||||
_currentPath = path;
|
_currentPath = path;
|
||||||
@@ -55,6 +57,8 @@ class VideoRecorder {
|
|||||||
}
|
}
|
||||||
final p = _currentPath;
|
final p = _currentPath;
|
||||||
_currentPath = null;
|
_currentPath = null;
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[VideoRecorder] 录制结束,已保存至:$p');
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,20 @@ web_rtc_controller_ios/
|
|||||||
- 导航键:返回 / 主页 / 多任务(Android keyCode 4 / 3 / 187)
|
- 导航键:返回 / 主页 / 多任务(Android keyCode 4 / 3 / 187)
|
||||||
- 分辨率切换:原始画质 / 1080P / 720P / 480P
|
- 分辨率切换:原始画质 / 1080P / 720P / 480P
|
||||||
- 串流模式切换:WebRTC(SRTP 视频轨)/ 自编码(DataChannel H.264 裸流 + VideoToolbox 硬解)
|
- 串流模式切换:WebRTC(SRTP 视频轨)/ 自编码(DataChannel H.264 裸流 + VideoToolbox 硬解)
|
||||||
|
- 远端视频录制:连接后可将收到的画面录制为 MP4(两种串流模式均支持)
|
||||||
- 实时统计:分辨率、帧率、码率、RTT、链路类型(P2P/TURN)、编解码器等
|
- 实时统计:分辨率、帧率、码率、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 依赖。
|
1. 用 Xcode 打开 `web_rtc_controller_ios.xcodeproj`,首次打开等待 SPM 解析 WebRTC 依赖。
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
FB000000000000000000000E /* DeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000E /* DeviceUtils.swift */; };
|
FB000000000000000000000E /* DeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000E /* DeviceUtils.swift */; };
|
||||||
FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; };
|
FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; };
|
||||||
FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; };
|
FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; };
|
||||||
|
FB0000000000000000000012 /* VideoRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000012 /* VideoRecorder.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
FA000000000000000000000F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
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>"; };
|
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; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -120,6 +122,7 @@
|
|||||||
children = (
|
children = (
|
||||||
FA0000000000000000000006 /* WebRTCClient.swift */,
|
FA0000000000000000000006 /* WebRTCClient.swift */,
|
||||||
FA0000000000000000000007 /* SelfCodecDecoder.swift */,
|
FA0000000000000000000007 /* SelfCodecDecoder.swift */,
|
||||||
|
FA0000000000000000000012 /* VideoRecorder.swift */,
|
||||||
);
|
);
|
||||||
path = WebRTC;
|
path = WebRTC;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -183,7 +186,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 1500;
|
LastSwiftUpdateCheck = 1500;
|
||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 2630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
FD0000000000000000000002 = {
|
FD0000000000000000000002 = {
|
||||||
CreatedOnToolsVersion = 15.0;
|
CreatedOnToolsVersion = 15.0;
|
||||||
@@ -235,6 +238,7 @@
|
|||||||
FB0000000000000000000005 /* ControlMessage.swift in Sources */,
|
FB0000000000000000000005 /* ControlMessage.swift in Sources */,
|
||||||
FB0000000000000000000006 /* WebRTCClient.swift in Sources */,
|
FB0000000000000000000006 /* WebRTCClient.swift in Sources */,
|
||||||
FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */,
|
FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */,
|
||||||
|
FB0000000000000000000012 /* VideoRecorder.swift in Sources */,
|
||||||
FB0000000000000000000008 /* RemoteTouchView.swift in Sources */,
|
FB0000000000000000000008 /* RemoteTouchView.swift in Sources */,
|
||||||
FB0000000000000000000009 /* RemoteVideoView.swift in Sources */,
|
FB0000000000000000000009 /* RemoteVideoView.swift in Sources */,
|
||||||
FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */,
|
FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */,
|
||||||
@@ -253,6 +257,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -306,6 +311,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -316,6 +322,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -362,6 +369,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
@@ -444,8 +452,8 @@
|
|||||||
repositoryURL = "https://github.com/stasel/WebRTC.git";
|
repositoryURL = "https://github.com/stasel/WebRTC.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = versionRange;
|
kind = versionRange;
|
||||||
minimumVersion = 120.0.0;
|
|
||||||
maximumVersion = 200.0.0;
|
maximumVersion = 200.0.0;
|
||||||
|
minimumVersion = 120.0.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
+15
@@ -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
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
+14
@@ -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>
|
||||||
@@ -19,6 +19,10 @@ final class SignalingClient: NSObject {
|
|||||||
private var session: URLSession?
|
private var session: URLSession?
|
||||||
private var task: URLSessionWebSocketTask?
|
private var task: URLSessionWebSocketTask?
|
||||||
private var manuallyClosed = false
|
private var manuallyClosed = false
|
||||||
|
/// WebSocket 握手是否已完成(didOpen 后置为 true)
|
||||||
|
private var isOpened = false
|
||||||
|
/// 是否已上报过断开/失败,避免重复回调
|
||||||
|
private var didNotifyClosure = false
|
||||||
|
|
||||||
init(serverUrl: String, deviceId: String) {
|
init(serverUrl: String, deviceId: String) {
|
||||||
self.serverUrl = serverUrl
|
self.serverUrl = serverUrl
|
||||||
@@ -32,16 +36,20 @@ final class SignalingClient: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
manuallyClosed = false
|
manuallyClosed = false
|
||||||
|
isOpened = false
|
||||||
|
didNotifyClosure = false
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.timeoutIntervalForRequest = 15
|
config.timeoutIntervalForRequest = 15
|
||||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||||
task = session?.webSocketTask(with: url)
|
task = session?.webSocketTask(with: url)
|
||||||
|
// 接收循环在 didOpenWithProtocol(握手完成)后再启动,
|
||||||
|
// 避免在 socket 未真正连接时调用 receive 触发 Code 57。
|
||||||
task?.resume()
|
task?.resume()
|
||||||
receiveLoop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
manuallyClosed = true
|
manuallyClosed = true
|
||||||
|
isOpened = false
|
||||||
task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8))
|
task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8))
|
||||||
task = nil
|
task = nil
|
||||||
session?.invalidateAndCancel()
|
session?.invalidateAndCancel()
|
||||||
@@ -85,9 +93,31 @@ final class SignalingClient: NSObject {
|
|||||||
}
|
}
|
||||||
self.receiveLoop()
|
self.receiveLoop()
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if !self.manuallyClosed {
|
self.handleReceiveFailure(error)
|
||||||
self.notifyError(error.localizedDescription)
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理接收失败:区分"正常断开"与"连接失败"。
|
||||||
|
/// 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,
|
func urlSession(_ session: URLSession,
|
||||||
webSocketTask: URLSessionWebSocketTask,
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
didOpenWithProtocol protocol: String?) {
|
didOpenWithProtocol protocol: String?) {
|
||||||
|
isOpened = true
|
||||||
|
// 握手完成后再启动接收循环,避免 socket 未连接时 receive 报错
|
||||||
|
receiveLoop()
|
||||||
registerDevice()
|
registerDevice()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.delegate?.signalingDidConnect()
|
self.delegate?.signalingDidConnect()
|
||||||
@@ -116,7 +149,9 @@ extension SignalingClient: URLSessionWebSocketDelegate {
|
|||||||
webSocketTask: URLSessionWebSocketTask,
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||||
reason: Data?) {
|
reason: Data?) {
|
||||||
guard !manuallyClosed else { return }
|
isOpened = false
|
||||||
|
guard !manuallyClosed, !didNotifyClosure else { return }
|
||||||
|
didNotifyClosure = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.delegate?.signalingDidDisconnect()
|
self.delegate?.signalingDidDisconnect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,12 +48,14 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
// MARK: - UI 状态
|
// MARK: - UI 状态
|
||||||
|
|
||||||
@Published var serverUrl: String = "wss://www.ttstd.com/signal"
|
@Published var serverUrl: String = "wss://www.ttstd.com/signal"
|
||||||
@Published var targetDeviceId: String = ""
|
@Published var targetDeviceId: String = "981964879"
|
||||||
@Published var statusText: String = "未连接"
|
@Published var statusText: String = "未连接"
|
||||||
/// 是否显示控制界面(对应 Android 的 setupPanel/controlPanel 切换)
|
/// 是否显示控制界面(对应 Android 的 setupPanel/controlPanel 切换)
|
||||||
@Published var isControlling: Bool = false
|
@Published var isControlling: Bool = false
|
||||||
@Published var isConnecting: Bool = false
|
@Published var isConnecting: Bool = false
|
||||||
@Published var statsText: String = ""
|
@Published var statsText: String = ""
|
||||||
|
/// 统计信息面板是否展开(默认收起,仅显示概要)
|
||||||
|
@Published var statsExpanded: Bool = false
|
||||||
@Published var streamMode: StreamMode = .webrtc
|
@Published var streamMode: StreamMode = .webrtc
|
||||||
@Published var selectedResolution: ResolutionOption = ResolutionOption.all[0]
|
@Published var selectedResolution: ResolutionOption = ResolutionOption.all[0]
|
||||||
/// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准
|
/// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准
|
||||||
@@ -65,6 +67,11 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
@Published var showAuthSheet: Bool = false
|
@Published var showAuthSheet: Bool = false
|
||||||
@Published var alertMessage: String?
|
@Published var alertMessage: String?
|
||||||
|
|
||||||
|
/// 是否正在录制远端视频
|
||||||
|
@Published var isRecording: Bool = false
|
||||||
|
/// 最近一次录制保存的文件路径(沙盒内)
|
||||||
|
@Published var lastRecordingPath: String?
|
||||||
|
|
||||||
let myDeviceId: String = DeviceUtils.deviceId()
|
let myDeviceId: String = DeviceUtils.deviceId()
|
||||||
|
|
||||||
// MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入)
|
// MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入)
|
||||||
@@ -77,6 +84,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
private var signalingClient: SignalingClient?
|
private var signalingClient: SignalingClient?
|
||||||
private var webRTCClient: WebRTCClient?
|
private var webRTCClient: WebRTCClient?
|
||||||
private let selfCodecDecoder = SelfCodecDecoder()
|
private let selfCodecDecoder = SelfCodecDecoder()
|
||||||
|
/// 远端视频录制器(MP4),跟随两种串流模式工作
|
||||||
|
private let videoRecorder = VideoRecorder()
|
||||||
|
|
||||||
private var statsTimer: Timer?
|
private var statsTimer: Timer?
|
||||||
private var connectionStartTime: Date?
|
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 }
|
guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return }
|
||||||
self.videoAspect = CGFloat(w) / CGFloat(h)
|
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: - 连接 / 断开
|
// MARK: - 连接 / 断开
|
||||||
@@ -134,6 +161,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
stopStatsTimer()
|
stopStatsTimer()
|
||||||
|
videoRecorder.cancel()
|
||||||
|
isRecording = false
|
||||||
selfCodecDecoder.setEnabled(false)
|
selfCodecDecoder.setEnabled(false)
|
||||||
webRTCClient?.close()
|
webRTCClient?.close()
|
||||||
webRTCClient = nil
|
webRTCClient = nil
|
||||||
@@ -225,6 +254,25 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
selfCodecDecoder.setEnabled(mode == .selfCodec)
|
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: - 私有
|
// MARK: - 私有
|
||||||
|
|
||||||
fileprivate func startWebRTC() {
|
fileprivate func startWebRTC() {
|
||||||
@@ -233,6 +281,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
|||||||
let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId)
|
let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId)
|
||||||
client.delegate = self
|
client.delegate = self
|
||||||
client.selfCodecDecoder = selfCodecDecoder
|
client.selfCodecDecoder = selfCodecDecoder
|
||||||
|
client.extraRenderer = videoRecorder
|
||||||
webRTCClient = client
|
webRTCClient = client
|
||||||
client.createOffer(
|
client.createOffer(
|
||||||
targetDeviceId: targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines),
|
targetDeviceId: targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ private struct SetupPanelView: View {
|
|||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
}
|
}
|
||||||
Text(viewModel.isConnecting ? "连接中..." : "连接")
|
Text(viewModel.isConnecting ? "连接中..." : "连接")
|
||||||
.fontWeight(.semibold)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,84 +101,150 @@ private struct ControlPanelView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
statsPanel
|
||||||
topBar
|
topBar
|
||||||
videoArea
|
videoArea
|
||||||
statsView
|
|
||||||
navButtons
|
navButtons
|
||||||
}
|
}
|
||||||
.background(Color.black.ignoresSafeArea())
|
.background(Color.black.ignoresSafeArea())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var topBar: some View {
|
/// 统计信息面板:置于最上方,点击可展开/收起详情
|
||||||
HStack(spacing: 12) {
|
private var statsPanel: some View {
|
||||||
Text(viewModel.statusText)
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
.font(.footnote)
|
Button {
|
||||||
.foregroundColor(.green)
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
.lineLimit(1)
|
viewModel.statsExpanded.toggle()
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
|
HStack(spacing: 8) {
|
||||||
.font(.footnote)
|
Image(systemName: "chart.bar.xaxis")
|
||||||
}
|
.font(.system(size: 11))
|
||||||
|
Text(statsSummary)
|
||||||
// 帧率选择(档位来自被控端 REPORT_RESOLUTION 上报)
|
.font(.system(size: 11, design: .monospaced))
|
||||||
Menu {
|
.lineLimit(1)
|
||||||
ForEach(viewModel.fpsOptions, id: \.self) { fps in
|
.truncationMode(.tail)
|
||||||
Button {
|
Spacer()
|
||||||
viewModel.selectFps(fps)
|
Image(systemName: viewModel.statsExpanded ? "chevron.up" : "chevron.down")
|
||||||
} label: {
|
.font(.system(size: 11, weight: .semibold))
|
||||||
if fps == viewModel.currentFps {
|
|
||||||
Label("\(fps)fps", systemImage: "checkmark")
|
|
||||||
} else {
|
|
||||||
Text("\(fps)fps")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} label: {
|
.foregroundColor(Color(white: 0.8))
|
||||||
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
|
.contentShape(Rectangle())
|
||||||
systemImage: "speedometer")
|
|
||||||
.font(.footnote)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button(role: .destructive) {
|
if viewModel.statsExpanded {
|
||||||
viewModel.disconnect()
|
Text(viewModel.statsText.isEmpty ? "暂无统计数据" : viewModel.statsText)
|
||||||
} label: {
|
.font(.system(size: 11, design: .monospaced))
|
||||||
Text("断开")
|
.foregroundColor(Color(white: 0.75))
|
||||||
.font(.footnote)
|
.multilineTextAlignment(.leading)
|
||||||
.fontWeight(.semibold)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.red)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.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))
|
.background(Color(white: 0.1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,23 +271,30 @@ private struct ControlPanelView: View {
|
|||||||
}
|
}
|
||||||
.aspectRatio(viewModel.videoAspect, contentMode: .fit)
|
.aspectRatio(viewModel.videoAspect, contentMode: .fit)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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 {
|
private var navButtons: some View {
|
||||||
HStack(spacing: 24) {
|
HStack(spacing: 24) {
|
||||||
navButton(title: "返回", systemImage: "arrow.uturn.backward") {
|
navButton(title: "返回", systemImage: "arrow.uturn.backward") {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ final class SelfCodecDecoder {
|
|||||||
weak var displayLayer: AVSampleBufferDisplayLayer?
|
weak var displayLayer: AVSampleBufferDisplayLayer?
|
||||||
/// 分辨率变化回调(主线程)
|
/// 分辨率变化回调(主线程)
|
||||||
var onResolutionUpdate: ((Int, Int) -> Void)?
|
var onResolutionUpdate: ((Int, Int) -> Void)?
|
||||||
|
/// 自编码模式录制回调:解码出的 AVCC CMSampleBuffer 会回传(由 VideoRecorder 写盘)
|
||||||
|
var onDecodedSampleBuffer: ((CMSampleBuffer, Bool) -> Void)?
|
||||||
|
|
||||||
private(set) var videoWidth = 0
|
private(set) var videoWidth = 0
|
||||||
private(set) var videoHeight = 0
|
private(set) var videoHeight = 0
|
||||||
@@ -268,6 +270,9 @@ final class SelfCodecDecoder {
|
|||||||
layer.flush()
|
layer.flush()
|
||||||
}
|
}
|
||||||
layer.enqueue(sampleBuffer)
|
layer.enqueue(sampleBuffer)
|
||||||
|
|
||||||
|
// 自编码模式录制:把解码后的 AVCC 帧回传给录制器写盘
|
||||||
|
onDecodedSampleBuffer?(sampleBuffer, isKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Annex-B 拆分
|
// MARK: - Annex-B 拆分
|
||||||
|
|||||||
@@ -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 包装成带时间戳的 CMSampleBuffer(WebRTC 模式写盘用)
|
||||||
|
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: - RTCVideoRenderer(WebRTC 模式)
|
||||||
|
|
||||||
|
extension VideoRecorder: RTCVideoRenderer {
|
||||||
|
func setSize(_ size: CGSize) {
|
||||||
|
// 写盘分辨率在收到首帧 CVPixelBuffer 时确定,这里无需处理
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFrame(_ frame: RTCVideoFrame?) {
|
||||||
|
guard let frame else { return }
|
||||||
|
handleWebRTCFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ final class WebRTCClient: NSObject {
|
|||||||
private var videoChannel: RTCDataChannel?
|
private var videoChannel: RTCDataChannel?
|
||||||
private weak var remoteRenderer: RTCVideoRenderer?
|
private weak var remoteRenderer: RTCVideoRenderer?
|
||||||
private var remoteVideoTrack: RTCVideoTrack?
|
private var remoteVideoTrack: RTCVideoTrack?
|
||||||
|
/// 额外的渲染器(用于把远端视频帧 fan-out 给录制器,WebRTC 模式)
|
||||||
|
weak var extraRenderer: RTCVideoRenderer?
|
||||||
private var connected = false
|
private var connected = false
|
||||||
|
|
||||||
init(signaling: SignalingClient, myDeviceId: String) {
|
init(signaling: SignalingClient, myDeviceId: String) {
|
||||||
@@ -208,6 +210,9 @@ final class WebRTCClient: NSObject {
|
|||||||
if let track = remoteVideoTrack, let renderer = remoteRenderer {
|
if let track = remoteVideoTrack, let renderer = remoteRenderer {
|
||||||
track.remove(renderer)
|
track.remove(renderer)
|
||||||
}
|
}
|
||||||
|
if let track = remoteVideoTrack, let extra = extraRenderer {
|
||||||
|
track.remove(extra)
|
||||||
|
}
|
||||||
remoteVideoTrack = nil
|
remoteVideoTrack = nil
|
||||||
peerConnection?.close()
|
peerConnection?.close()
|
||||||
peerConnection = nil
|
peerConnection = nil
|
||||||
@@ -289,6 +294,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
if let renderer = self.remoteRenderer {
|
if let renderer = self.remoteRenderer {
|
||||||
track.add(renderer)
|
track.add(renderer)
|
||||||
}
|
}
|
||||||
|
if let extra = self.extraRenderer {
|
||||||
|
track.add(extra)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user