feat(ios): 添加远端视频录制功能并优化信令连接稳定性
- 新增 VideoRecorder 组件,支持 WebRTC 和自编码两种模式录制远端视频为 MP4 - 优化 SignalingClient 连接管理,修复 WebSocket 握手前 receive 导致的断开问题 - 重构控制面板 UI,支持统计信息折叠展开和录制控制按钮 - 添加录制状态指示、保存路径提示及断开自动取消录制逻辑 - 更新项目配置和文档,补充录制功能说明
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 拆分
|
||||
|
||||
@@ -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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user