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 0000000..76df4b2 Binary files /dev/null and b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.xcworkspace/xcuserdata/tt.xcuserdatad/UserInterfaceState.xcuserstate differ 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) + } } } }