From a73f61f36695824ab454787cf9136c6d8a794dcc Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Sat, 1 Aug 2026 03:59:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20=E6=B7=BB=E5=8A=A0=E8=BF=9C?= =?UTF-8?q?=E7=AB=AF=E8=A7=86=E9=A2=91=E5=BD=95=E5=88=B6=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BF=A1=E4=BB=A4=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 VideoRecorder 组件,支持 WebRTC 和自编码两种模式录制远端视频为 MP4 - 优化 SignalingClient 连接管理,修复 WebSocket 握手前 receive 导致的断开问题 - 重构控制面板 UI,支持统计信息折叠展开和录制控制按钮 - 添加录制状态指示、保存路径提示及断开自动取消录制逻辑 - 更新项目配置和文档,补充录制功能说明 --- .../lib/webrtc/video_recorder.dart | 4 + webrtc_controller_ios/README.md | 12 + .../project.pbxproj | 12 +- .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 15 + .../UserInterfaceState.xcuserstate | Bin 0 -> 41004 bytes .../xcschemes/xcschememanagement.plist | 14 + .../Signaling/SignalingClient.swift | 45 ++- .../ViewModel/ControllerViewModel.swift | 51 ++- .../Views/ContentView.swift | 225 ++++++++---- .../WebRTC/SelfCodecDecoder.swift | 5 + .../WebRTC/VideoRecorder.swift | 337 ++++++++++++++++++ .../WebRTC/WebRTCClient.swift | 8 + 13 files changed, 651 insertions(+), 84 deletions(-) create mode 100644 webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcuserdata/tt.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/xcuserdata/tt.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 webrtc_controller_ios/web_rtc_controller_ios/WebRTC/VideoRecorder.swift diff --git a/webrtc_controller_flutter/lib/webrtc/video_recorder.dart b/webrtc_controller_flutter/lib/webrtc/video_recorder.dart index ac9bfe4..53a54b1 100644 --- a/webrtc_controller_flutter/lib/webrtc/video_recorder.dart +++ b/webrtc_controller_flutter/lib/webrtc/video_recorder.dart @@ -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; } diff --git a/webrtc_controller_ios/README.md b/webrtc_controller_ios/README.md index 2b3aba2..9770a32 100644 --- a/webrtc_controller_ios/README.md +++ b/webrtc_controller_ios/README.md @@ -47,8 +47,20 @@ web_rtc_controller_ios/ - 导航键:返回 / 主页 / 多任务(Android keyCode 4 / 3 / 187) - 分辨率切换:原始画质 / 1080P / 720P / 480P - 串流模式切换:WebRTC(SRTP 视频轨)/ 自编码(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 依赖。 diff --git a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj index f0b346f..9c3061f 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj +++ b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj @@ -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 = ""; }; FA0000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -120,6 +122,7 @@ children = ( FA0000000000000000000006 /* WebRTCClient.swift */, FA0000000000000000000007 /* SelfCodecDecoder.swift */, + FA0000000000000000000012 /* VideoRecorder.swift */, ); path = WebRTC; sourceTree = ""; @@ -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 */ diff --git a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..55e026c --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcuserdata/tt.xcuserdatad/UserInterfaceState.xcuserstate b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcuserdata/tt.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..76df4b2488825d74e6990332337d7c475a2941ad GIT binary patch literal 41004 zcmeFa2V7Lg_6L4v?(W{QAd4UZ0@6WbVH@m%B5Yx$x21y;gjG?REJTeZcVddhG)qi0 zCMueuMonT~s)?reqS5r2V${SWCZ@;4&>^->$Dd#J6{!_*_xqts*67!G-y1^Kod|V%0f1jjq*_eDnX^F2GycEqJ%ye|&!N3&A9@SzM+eZ`=pFPi z`UHK7K1WB;m*^PAn8q?J#|q3~7IT=#O6-Mw(WQ6<4!|RE6pqGYu@>uaDo)4a@OYes z%{Uv|aRDyGrMLoD;yT=bo3IOa;~sn|o{i_=x%e_X4_}3^#@FD*_&R(Cz7yYz@52w` zNANEE1bzy?fM3L~;8*c$_%J?#kK!-zm-ra|3V)5i!N>8p_y>Fv|BnB_r)W8?pc&eS zR?)una5{tzrNijabQB#+C(vW*L|RR!(5bYMHqmKx2AxIQ>4|g!T}fBb)pQMAOV`m( zdMZ7Qo=MN9=g@QM%jkLZHS}V734JZSlwL=#r#H~O^hSCUeJ6bv_Mz{lx6#|_o%9p* zlk{%-CHiIh75Y_rAN>~n0sSHU5&b*;2YrhElm1Ia$&d`oXqikVmnme7jFpX$1<8VC zVY1P(C|R^DUN%OimL#{dw zZ_4({4#?h1%Om8G@

o+M9} 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 literal 0 HcmV?d00001 diff --git a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/xcuserdata/tt.xcuserdatad/xcschemes/xcschememanagement.plist b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/xcuserdata/tt.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..f5d2f3c --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/xcuserdata/tt.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + web_rtc_controller_ios.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift index 48001cf..1d510ca 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift @@ -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() } diff --git a/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift index 1d25389..17d7088 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift @@ -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), diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift index b3d0338..436d02c 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift @@ -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") { diff --git a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/SelfCodecDecoder.swift b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/SelfCodecDecoder.swift index 61a4175..640261e 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/SelfCodecDecoder.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/SelfCodecDecoder.swift @@ -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 拆分 diff --git a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/VideoRecorder.swift b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/VideoRecorder.swift new file mode 100644 index 0000000..eb4a2f8 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/VideoRecorder.swift @@ -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) + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift index dc5e26e..f329e63 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift @@ -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) + } } } }