@stream-io/video-react-native-sdk 1.37.0 → 1.37.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +81 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +436 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +17 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +36 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +60 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +31 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +329 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +472 -0
- package/dist/commonjs/hooks/index.js +11 -0
- package/dist/commonjs/hooks/index.js.map +1 -1
- package/dist/commonjs/hooks/useLoopbackRecording.js +243 -0
- package/dist/commonjs/hooks/useLoopbackRecording.js.map +1 -0
- package/dist/commonjs/utils/internal/callingx/callingx.js +2 -2
- package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/commonjs/version.js.map +1 -1
- package/dist/module/hooks/index.js +1 -0
- package/dist/module/hooks/index.js.map +1 -1
- package/dist/module/hooks/useLoopbackRecording.js +238 -0
- package/dist/module/hooks/useLoopbackRecording.js.map +1 -0
- package/dist/module/utils/internal/callingx/callingx.js +2 -2
- package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/module/version.js +1 -1
- package/dist/module/version.js.map +1 -1
- package/dist/typescript/hooks/index.d.ts +1 -0
- package/dist/typescript/hooks/index.d.ts.map +1 -1
- package/dist/typescript/hooks/useLoopbackRecording.d.ts +85 -0
- package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +1 -0
- package/dist/typescript/version.d.ts +1 -1
- package/dist/typescript/version.d.ts.map +1 -1
- package/ios/StreamVideoReactNative-Bridging-Header.h +2 -0
- package/ios/StreamVideoReactNative.m +81 -0
- package/ios/TracksRecorder/AudioPipeline.swift +270 -0
- package/ios/TracksRecorder/PipelineHost.swift +56 -0
- package/ios/TracksRecorder/RecorderAudioRenderTap.swift +154 -0
- package/ios/TracksRecorder/RecorderVideoSink.swift +137 -0
- package/ios/TracksRecorder/TracksRecorderManager.swift +327 -0
- package/ios/TracksRecorder/VideoPipeline.swift +297 -0
- package/package.json +8 -8
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useLoopbackRecording.ts +438 -0
- package/src/utils/internal/callingx/callingx.ts +2 -2
- package/src/version.ts +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright © 2026 Stream.io Inc. All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import AVFoundation
|
|
6
|
+
import CoreMedia
|
|
7
|
+
import Foundation
|
|
8
|
+
import WebRTC
|
|
9
|
+
|
|
10
|
+
/// Render-side audio tap used by `TracksRecorderManager`. Implements
|
|
11
|
+
/// `RTCAudioCustomProcessingDelegate` and is installed on
|
|
12
|
+
/// `RTCDefaultAudioProcessingModule.renderPreProcessingDelegate` for the
|
|
13
|
+
/// duration of a recording.
|
|
14
|
+
///
|
|
15
|
+
/// The render path WebRTC is using:
|
|
16
|
+
/// ```
|
|
17
|
+
/// SFU → decoder → audio mixer → renderPreProcessingDelegate →
|
|
18
|
+
/// render-side processing → speaker
|
|
19
|
+
/// ```
|
|
20
|
+
///
|
|
21
|
+
/// What the delegate sees per call: an `RTCAudioBuffer` that holds the
|
|
22
|
+
/// **post-mix** decoded audio about to be played to the speaker. In a
|
|
23
|
+
/// self-sub-only call, that's exactly the SFU echo of the local mic. In a
|
|
24
|
+
/// call with multiple remote participants the buffer contains the
|
|
25
|
+
/// post-mix output (everyone blended together).
|
|
26
|
+
///
|
|
27
|
+
/// **Important:** `RTCAudioBuffer` exposes `rawBuffer(forChannel:)` as
|
|
28
|
+
/// `UnsafeMutablePointer<Float>` in **FloatS16** format — i.e. Float32 values
|
|
29
|
+
/// in the Int16 range -32768…32767. Cast/clamp to `Int16` for the PCM
|
|
30
|
+
/// destination buffer (no normalisation needed).
|
|
31
|
+
///
|
|
32
|
+
/// **Threading:** all three protocol methods run on a WebRTC audio
|
|
33
|
+
/// processing thread. The buffer handler closure is invoked from there; the
|
|
34
|
+
/// caller is responsible for hopping queues if needed.
|
|
35
|
+
///
|
|
36
|
+
/// **Lifetime:** `RTCDefaultAudioProcessingModule.renderPreProcessingDelegate`
|
|
37
|
+
/// is `weak`, so the manager must keep this instance alive for the duration
|
|
38
|
+
/// of recording.
|
|
39
|
+
@objc public final class RecorderAudioRenderTap: NSObject, RTCAudioCustomProcessingDelegate {
|
|
40
|
+
|
|
41
|
+
typealias BufferHandler = (AVAudioPCMBuffer) -> Void
|
|
42
|
+
|
|
43
|
+
private let bufferHandler: BufferHandler
|
|
44
|
+
|
|
45
|
+
/// When `true`, the WebRTC `RTCAudioBuffer` is zero-filled in place
|
|
46
|
+
/// *after* the samples have been copied into the recording PCM buffer.
|
|
47
|
+
/// The recording keeps the original audio; everything downstream of
|
|
48
|
+
/// this delegate (render-side APM → audio device module → speaker)
|
|
49
|
+
/// sees silence. This yields "audio in the file, silence at the
|
|
50
|
+
/// speaker" without disrupting the recording — `track.setVolume(0)`
|
|
51
|
+
/// / `track.isEnabled = false` mutes apply *before* this tap and
|
|
52
|
+
/// would silence the recording too.
|
|
53
|
+
///
|
|
54
|
+
/// Side effect to be aware of: this mutes the entire post-mix
|
|
55
|
+
/// playback, not just one track. In a self-sub-only call post-mix ==
|
|
56
|
+
/// loopback, so it's effectively a per-track mute. With other remote
|
|
57
|
+
/// participants in the call they would be muted at the speaker too
|
|
58
|
+
/// while recording is active.
|
|
59
|
+
private let muteOriginal: Bool
|
|
60
|
+
|
|
61
|
+
private var processingSampleRate: Double = 0
|
|
62
|
+
private var processingChannels: Int = 0
|
|
63
|
+
private var avFormat: AVAudioFormat?
|
|
64
|
+
|
|
65
|
+
/// Atomic-style call counter — exposes whether the APM is invoking
|
|
66
|
+
/// `audioProcessingProcess(audioBuffer:)` at all.
|
|
67
|
+
private let counterLock = NSLock()
|
|
68
|
+
private var _callCount: Int = 0
|
|
69
|
+
@objc public var callCount: Int {
|
|
70
|
+
counterLock.lock()
|
|
71
|
+
defer { counterLock.unlock() }
|
|
72
|
+
return _callCount
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
init(muteOriginal: Bool, bufferHandler: @escaping BufferHandler) {
|
|
76
|
+
self.muteOriginal = muteOriginal
|
|
77
|
+
self.bufferHandler = bufferHandler
|
|
78
|
+
super.init()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// MARK: - RTCAudioCustomProcessingDelegate
|
|
82
|
+
|
|
83
|
+
public func audioProcessingInitialize(sampleRate: Int, channels: Int) {
|
|
84
|
+
processingSampleRate = Double(sampleRate)
|
|
85
|
+
processingChannels = channels
|
|
86
|
+
avFormat = AVAudioFormat(
|
|
87
|
+
commonFormat: .pcmFormatInt16,
|
|
88
|
+
sampleRate: processingSampleRate,
|
|
89
|
+
channels: AVAudioChannelCount(channels),
|
|
90
|
+
interleaved: false
|
|
91
|
+
)
|
|
92
|
+
NSLog("[TracksRecorder] RenderTap initialize sampleRate=%d channels=%d",
|
|
93
|
+
sampleRate, channels)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public func audioProcessingProcess(audioBuffer: RTCAudioBuffer) {
|
|
97
|
+
counterLock.lock()
|
|
98
|
+
_callCount += 1
|
|
99
|
+
let count = _callCount
|
|
100
|
+
counterLock.unlock()
|
|
101
|
+
|
|
102
|
+
if count == 1 {
|
|
103
|
+
NSLog("[TracksRecorder] RenderTap FIRST call frames=%d channels=%d",
|
|
104
|
+
audioBuffer.frames, audioBuffer.channels)
|
|
105
|
+
} else if count % 100 == 0 {
|
|
106
|
+
NSLog("[TracksRecorder] RenderTap call #%d", count)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
guard let format = avFormat else { return }
|
|
110
|
+
let frames = Int(audioBuffer.frames)
|
|
111
|
+
let channels = Int(audioBuffer.channels)
|
|
112
|
+
guard frames > 0, channels > 0 else { return }
|
|
113
|
+
|
|
114
|
+
guard let pcm = AVAudioPCMBuffer(
|
|
115
|
+
pcmFormat: format,
|
|
116
|
+
frameCapacity: AVAudioFrameCount(frames)
|
|
117
|
+
) else {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
pcm.frameLength = AVAudioFrameCount(frames)
|
|
121
|
+
guard let dst = pcm.int16ChannelData else { return }
|
|
122
|
+
|
|
123
|
+
// Copy each channel: FloatS16 (Float32 in Int16 range) → Int16.
|
|
124
|
+
// No normalisation needed — values already span the Int16 range.
|
|
125
|
+
for ch in 0..<channels {
|
|
126
|
+
let src = audioBuffer.rawBuffer(forChannel: ch)
|
|
127
|
+
let dstChannel = dst[ch]
|
|
128
|
+
for i in 0..<frames {
|
|
129
|
+
let v = src[i]
|
|
130
|
+
if v >= 32767 {
|
|
131
|
+
dstChannel[i] = Int16.max
|
|
132
|
+
} else if v <= -32768 {
|
|
133
|
+
dstChannel[i] = Int16.min
|
|
134
|
+
} else {
|
|
135
|
+
dstChannel[i] = Int16(v)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// If `muteOriginal` is on, zero the source buffer in the same pass
|
|
139
|
+
// so the data continuing downstream to the speaker is silence.
|
|
140
|
+
if muteOriginal {
|
|
141
|
+
src[i] = 0
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
bufferHandler(pcm)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public func audioProcessingRelease() {
|
|
150
|
+
avFormat = nil
|
|
151
|
+
// Deliberately preserve `_callCount` — useful in end-of-recording
|
|
152
|
+
// diagnostics even after release.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright © 2026 Stream.io Inc. All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import CoreMedia
|
|
6
|
+
import CoreVideo
|
|
7
|
+
import Foundation
|
|
8
|
+
import WebRTC
|
|
9
|
+
|
|
10
|
+
/// Per-track video sink used by `TracksRecorderManager`. Implements
|
|
11
|
+
/// `RTCVideoRenderer` so it can be attached directly to an `RTCVideoTrack`.
|
|
12
|
+
///
|
|
13
|
+
/// Each delivered `RTCVideoFrame` is normalised to a CVPixelBuffer in the
|
|
14
|
+
/// hardware H.264 encoder's native format — **NV12**
|
|
15
|
+
/// (`kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange`).
|
|
16
|
+
/// `RTCCVPixelBuffer` sources (camera passthrough) are forwarded with
|
|
17
|
+
/// the underlying pixel buffer unchanged. `RTCI420Buffer` and other YUV
|
|
18
|
+
/// sources are converted into a fresh IOSurface-backed NV12 buffer via
|
|
19
|
+
/// a plane reorder (no color-space conversion required).
|
|
20
|
+
///
|
|
21
|
+
/// **Why NV12 and not BGRA?** AVAssetWriter's hardware encoder accepts
|
|
22
|
+
/// both, but BGRA requires an internal colour-space conversion that
|
|
23
|
+
/// fails with VideoToolbox `-16364` on certain stride/alignment
|
|
24
|
+
/// combinations a few frames in. NV12 is the encoder's native input;
|
|
25
|
+
/// passing it directly bypasses the failure entirely.
|
|
26
|
+
///
|
|
27
|
+
/// Threading: `renderFrame` runs on a WebRTC frame-delivery thread; the
|
|
28
|
+
/// callback must be safe to invoke from there. The manager serialises
|
|
29
|
+
/// further access on its own queue.
|
|
30
|
+
@objc final class RecorderVideoSink: NSObject, RTCVideoRenderer {
|
|
31
|
+
|
|
32
|
+
typealias FrameHandler = (_ pixelBuffer: CVPixelBuffer, _ width: Int32, _ height: Int32, _ timestampNs: Int64) -> Void
|
|
33
|
+
|
|
34
|
+
private let frameHandler: FrameHandler
|
|
35
|
+
|
|
36
|
+
init(frameHandler: @escaping FrameHandler) {
|
|
37
|
+
self.frameHandler = frameHandler
|
|
38
|
+
super.init()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// MARK: - RTCVideoRenderer
|
|
42
|
+
|
|
43
|
+
func setSize(_ size: CGSize) {
|
|
44
|
+
// No-op: pixel buffer dimensions are derived from each incoming frame.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func renderFrame(_ frame: RTCVideoFrame?) {
|
|
48
|
+
guard let frame = frame, frame.width > 0, frame.height > 0 else { return }
|
|
49
|
+
|
|
50
|
+
let pixelBuffer: CVPixelBuffer?
|
|
51
|
+
if let cvBuffer = frame.buffer as? RTCCVPixelBuffer {
|
|
52
|
+
// Camera passthrough — already a CVPixelBuffer (typically NV12 on iOS).
|
|
53
|
+
pixelBuffer = cvBuffer.pixelBuffer
|
|
54
|
+
} else if let i420 = frame.buffer as? RTCI420Buffer {
|
|
55
|
+
pixelBuffer = Self.makeNV12PixelBuffer(fromI420: i420)
|
|
56
|
+
} else {
|
|
57
|
+
// Other YUV variants — normalise via toI420() first.
|
|
58
|
+
let i420 = frame.buffer.toI420()
|
|
59
|
+
if let concrete = i420 as? RTCI420Buffer {
|
|
60
|
+
pixelBuffer = Self.makeNV12PixelBuffer(fromI420: concrete)
|
|
61
|
+
} else {
|
|
62
|
+
pixelBuffer = nil
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
guard let outputBuffer = pixelBuffer else { return }
|
|
67
|
+
frameHandler(outputBuffer, frame.width, frame.height, frame.timeStampNs)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// MARK: - I420 → NV12
|
|
71
|
+
|
|
72
|
+
/// Allocates a fresh IOSurface-backed NV12 `CVPixelBuffer` and copies
|
|
73
|
+
/// the I420 source's planes into it (Y as-is, U+V interleaved into the
|
|
74
|
+
/// UV plane). No colour-space conversion — this is purely a plane
|
|
75
|
+
/// reorder, so the operation is both fast and bit-exact.
|
|
76
|
+
private static func makeNV12PixelBuffer(fromI420 i420: RTCI420Buffer) -> CVPixelBuffer? {
|
|
77
|
+
let width = Int(i420.width)
|
|
78
|
+
let height = Int(i420.height)
|
|
79
|
+
let chromaWidth = Int(i420.chromaWidth)
|
|
80
|
+
let chromaHeight = Int(i420.chromaHeight)
|
|
81
|
+
|
|
82
|
+
var pixelBuffer: CVPixelBuffer?
|
|
83
|
+
let attrs: [String: Any] = [
|
|
84
|
+
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any],
|
|
85
|
+
]
|
|
86
|
+
let status = CVPixelBufferCreate(
|
|
87
|
+
kCFAllocatorDefault,
|
|
88
|
+
width,
|
|
89
|
+
height,
|
|
90
|
+
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
|
91
|
+
attrs as CFDictionary,
|
|
92
|
+
&pixelBuffer
|
|
93
|
+
)
|
|
94
|
+
guard status == kCVReturnSuccess, let buffer = pixelBuffer else { return nil }
|
|
95
|
+
|
|
96
|
+
CVPixelBufferLockBaseAddress(buffer, [])
|
|
97
|
+
defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
|
|
98
|
+
|
|
99
|
+
// Plane 0: Y — direct copy.
|
|
100
|
+
guard let yDest = CVPixelBufferGetBaseAddressOfPlane(buffer, 0) else { return nil }
|
|
101
|
+
let yDestStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0)
|
|
102
|
+
let ySrcStride = Int(i420.strideY)
|
|
103
|
+
let ySrc = UnsafeRawPointer(i420.dataY)
|
|
104
|
+
if ySrcStride == yDestStride {
|
|
105
|
+
memcpy(yDest, ySrc, ySrcStride * height)
|
|
106
|
+
} else {
|
|
107
|
+
let copyBytes = min(ySrcStride, yDestStride)
|
|
108
|
+
for row in 0..<height {
|
|
109
|
+
memcpy(
|
|
110
|
+
yDest.advanced(by: row * yDestStride),
|
|
111
|
+
ySrc.advanced(by: row * ySrcStride),
|
|
112
|
+
copyBytes
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Plane 1: UV — interleave I420's U and V planes.
|
|
118
|
+
guard let uvDestRaw = CVPixelBufferGetBaseAddressOfPlane(buffer, 1) else { return nil }
|
|
119
|
+
let uvDestStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 1)
|
|
120
|
+
let uvDest = uvDestRaw.assumingMemoryBound(to: UInt8.self)
|
|
121
|
+
let uSrcStride = Int(i420.strideU)
|
|
122
|
+
let vSrcStride = Int(i420.strideV)
|
|
123
|
+
let uSrc = i420.dataU
|
|
124
|
+
let vSrc = i420.dataV
|
|
125
|
+
for row in 0..<chromaHeight {
|
|
126
|
+
let uRow = uSrc.advanced(by: row * uSrcStride)
|
|
127
|
+
let vRow = vSrc.advanced(by: row * vSrcStride)
|
|
128
|
+
let uvRow = uvDest.advanced(by: row * uvDestStride)
|
|
129
|
+
for col in 0..<chromaWidth {
|
|
130
|
+
uvRow[col * 2] = uRow[col]
|
|
131
|
+
uvRow[col * 2 + 1] = vRow[col]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return buffer
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright © 2026 Stream.io Inc. All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import AVFoundation
|
|
6
|
+
import CoreMedia
|
|
7
|
+
import Foundation
|
|
8
|
+
import WebRTC
|
|
9
|
+
|
|
10
|
+
/// Orchestrator for the React Native track recorder. Owns the
|
|
11
|
+
/// `AVAssetWriter`, the recording lifecycle, the writer-start gate, and
|
|
12
|
+
/// the terminal-completion barrier. Delegates the encoder + sink + drain
|
|
13
|
+
/// work to `VideoPipeline` and `AudioPipeline` respectively (composed via
|
|
14
|
+
/// `PipelineHost`).
|
|
15
|
+
///
|
|
16
|
+
/// The `startRecording` completion is the *lifecycle* signal — it fires once at the terminal moment of the
|
|
17
|
+
/// recording (auto-stop timer expired, manual stop, or fatal error),
|
|
18
|
+
/// carrying the resulting file URL or an error. The `stopRecording`
|
|
19
|
+
/// completion is just a synchronization point — `void` — so callers can
|
|
20
|
+
/// `await stopTrackRecording(); await getStreamRecordings()` without
|
|
21
|
+
/// racing the disk flush.
|
|
22
|
+
@objc public final class TracksRecorderManager: NSObject, PipelineHost {
|
|
23
|
+
|
|
24
|
+
@objc public static let shared = TracksRecorderManager()
|
|
25
|
+
|
|
26
|
+
// MARK: - Configuration
|
|
27
|
+
|
|
28
|
+
@objc public var recordingsDirectory: URL {
|
|
29
|
+
let tmp = NSTemporaryDirectory()
|
|
30
|
+
let url = URL(fileURLWithPath: tmp).appendingPathComponent("StreamRecordings", isDirectory: true)
|
|
31
|
+
try? FileManager.default.createDirectory(
|
|
32
|
+
at: url,
|
|
33
|
+
withIntermediateDirectories: true,
|
|
34
|
+
attributes: nil
|
|
35
|
+
)
|
|
36
|
+
return url
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// MARK: - PipelineHost
|
|
40
|
+
|
|
41
|
+
let queue = DispatchQueue(label: "io.stream.video.tracks-recorder")
|
|
42
|
+
|
|
43
|
+
private(set) var assetWriter: AVAssetWriter?
|
|
44
|
+
private(set) var isRecording = false
|
|
45
|
+
|
|
46
|
+
// MARK: - State
|
|
47
|
+
|
|
48
|
+
private var videoPipeline: VideoPipeline?
|
|
49
|
+
private var audioPipeline: AudioPipeline?
|
|
50
|
+
|
|
51
|
+
private var outputURL: URL?
|
|
52
|
+
private var recordingCompletion: ((URL?, NSError?) -> Void)?
|
|
53
|
+
private var isCompleted = false
|
|
54
|
+
|
|
55
|
+
private var pendingPipelines = 0
|
|
56
|
+
private var recordingStartHostTimeNs: UInt64?
|
|
57
|
+
private var autoStopTimer: DispatchSourceTimer?
|
|
58
|
+
|
|
59
|
+
// MARK: - Public API
|
|
60
|
+
|
|
61
|
+
@objc public func startRecording(
|
|
62
|
+
videoTrackId: String?,
|
|
63
|
+
maxDurationMs: Int,
|
|
64
|
+
targetWidth: Int,
|
|
65
|
+
targetHeight: Int,
|
|
66
|
+
webRTCModule: WebRTCModule,
|
|
67
|
+
completion: @escaping (URL?, NSError?) -> Void
|
|
68
|
+
) {
|
|
69
|
+
queue.async { [weak self] in
|
|
70
|
+
guard let self = self else { return }
|
|
71
|
+
|
|
72
|
+
if self.isRecording {
|
|
73
|
+
completion(nil, makeRecorderError("recording_in_progress", code: 1))
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let videoTrack = videoTrackId
|
|
78
|
+
.flatMap { webRTCModule.track(forId: $0) } as? RTCVideoTrack
|
|
79
|
+
|
|
80
|
+
// Resolve the APM for the render-side audio tap before opening
|
|
81
|
+
// any files — so a missing/incompatible APM fails fast. Audio
|
|
82
|
+
// is captured through the APM render-pre delegate (post-mix
|
|
83
|
+
// decoded audio); no per-track lookup is needed.
|
|
84
|
+
let apmId = WebRTCModuleOptions.sharedInstance().audioProcessingModule
|
|
85
|
+
guard let apm = apmId as? RTCDefaultAudioProcessingModule else {
|
|
86
|
+
completion(nil, makeRecorderError("audio_processing_module_unavailable", code: 5))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if apm.renderPreProcessingDelegate != nil {
|
|
90
|
+
completion(nil, makeRecorderError("recording_blocked_by_audio_render_tap", code: 6))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let dir = self.recordingsDirectory
|
|
95
|
+
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
|
96
|
+
let outputURL = dir.appendingPathComponent("recording_\(timestamp).mp4")
|
|
97
|
+
|
|
98
|
+
let writer: AVAssetWriter
|
|
99
|
+
do {
|
|
100
|
+
writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
|
|
101
|
+
} catch {
|
|
102
|
+
completion(nil, error as NSError)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
self.assetWriter = writer
|
|
107
|
+
self.outputURL = outputURL
|
|
108
|
+
self.recordingCompletion = completion
|
|
109
|
+
self.isCompleted = false
|
|
110
|
+
self.recordingStartHostTimeNs = nil
|
|
111
|
+
self.pendingPipelines = 0
|
|
112
|
+
self.isRecording = true
|
|
113
|
+
|
|
114
|
+
if let videoTrack = videoTrack {
|
|
115
|
+
let pipeline = VideoPipeline(
|
|
116
|
+
host: self,
|
|
117
|
+
videoTrack: videoTrack,
|
|
118
|
+
targetWidth: Int32(targetWidth),
|
|
119
|
+
targetHeight: Int32(targetHeight)
|
|
120
|
+
)
|
|
121
|
+
self.videoPipeline = pipeline
|
|
122
|
+
self.pendingPipelines += 1
|
|
123
|
+
pipeline.start()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let audio = AudioPipeline(host: self, apm: apm)
|
|
127
|
+
self.audioPipeline = audio
|
|
128
|
+
self.pendingPipelines += 1
|
|
129
|
+
audio.start()
|
|
130
|
+
|
|
131
|
+
if maxDurationMs > 0 {
|
|
132
|
+
let timer = DispatchSource.makeTimerSource(queue: self.queue)
|
|
133
|
+
timer.schedule(deadline: .now() + .milliseconds(maxDurationMs))
|
|
134
|
+
timer.setEventHandler { [weak self] in
|
|
135
|
+
self?.stopRecording { }
|
|
136
|
+
}
|
|
137
|
+
timer.resume()
|
|
138
|
+
self.autoStopTimer = timer
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
NSLog("[TracksRecorder] recording started video=%@ audio=YES → %@",
|
|
142
|
+
videoTrack != nil ? "YES" : "NO",
|
|
143
|
+
outputURL.absoluteString)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@objc public func stopRecording(completion: @escaping () -> Void) {
|
|
148
|
+
queue.async { [weak self] in
|
|
149
|
+
guard let self = self else {
|
|
150
|
+
DispatchQueue.main.async { completion() }
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if !self.isRecording {
|
|
155
|
+
DispatchQueue.main.async { completion() }
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
self.autoStopTimer?.cancel()
|
|
160
|
+
self.autoStopTimer = nil
|
|
161
|
+
|
|
162
|
+
// Detach sinks/taps so no more frames arrive on the recorder path.
|
|
163
|
+
self.videoPipeline?.detachSink()
|
|
164
|
+
self.audioPipeline?.detachSink()
|
|
165
|
+
|
|
166
|
+
let video = self.videoPipeline
|
|
167
|
+
let audio = self.audioPipeline
|
|
168
|
+
let writer = self.assetWriter
|
|
169
|
+
let outputURL = self.outputURL
|
|
170
|
+
|
|
171
|
+
self.isRecording = false
|
|
172
|
+
|
|
173
|
+
// If the writer never started (no encoded sample ever made it
|
|
174
|
+
// out), there's nothing to finalise.
|
|
175
|
+
guard let assetWriter = writer, assetWriter.status == .writing else {
|
|
176
|
+
NSLog("[TracksRecorder] stopRecording: writer not in .writing (status=%ld) — no file produced",
|
|
177
|
+
Int(writer?.status.rawValue ?? -1))
|
|
178
|
+
video?.logSummary()
|
|
179
|
+
audio?.logSummary()
|
|
180
|
+
self.fireTerminalCompletion(url: nil, error: writer?.error as NSError?)
|
|
181
|
+
self.cleanupAfterStop()
|
|
182
|
+
DispatchQueue.main.async { completion() }
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
video?.markInputAsFinished()
|
|
187
|
+
audio?.markInputAsFinished()
|
|
188
|
+
|
|
189
|
+
assetWriter.finishWriting { [weak self] in
|
|
190
|
+
guard let self = self else {
|
|
191
|
+
DispatchQueue.main.async { completion() }
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
self.queue.async {
|
|
195
|
+
let resolved: URL? = (assetWriter.status == .completed) ? outputURL : nil
|
|
196
|
+
let writerError: NSError? = (assetWriter.status == .failed)
|
|
197
|
+
? (assetWriter.error as NSError?)
|
|
198
|
+
: nil
|
|
199
|
+
video?.logSummary()
|
|
200
|
+
audio?.logSummary()
|
|
201
|
+
NSLog("[TracksRecorder] recording finalised → %@",
|
|
202
|
+
resolved?.absoluteString ?? "(no file produced)")
|
|
203
|
+
self.fireTerminalCompletion(url: resolved, error: writerError)
|
|
204
|
+
self.cleanupAfterStop()
|
|
205
|
+
DispatchQueue.main.async { completion() }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@objc public func clearRecordingsDirectory(completion: @escaping (NSError?) -> Void) {
|
|
212
|
+
queue.async { [weak self] in
|
|
213
|
+
guard let self = self else { return }
|
|
214
|
+
let fm = FileManager.default
|
|
215
|
+
do {
|
|
216
|
+
let contents = try fm.contentsOfDirectory(
|
|
217
|
+
at: self.recordingsDirectory,
|
|
218
|
+
includingPropertiesForKeys: nil,
|
|
219
|
+
options: []
|
|
220
|
+
)
|
|
221
|
+
for url in contents {
|
|
222
|
+
try? fm.removeItem(at: url)
|
|
223
|
+
}
|
|
224
|
+
DispatchQueue.main.async { completion(nil) }
|
|
225
|
+
} catch {
|
|
226
|
+
DispatchQueue.main.async { completion(error as NSError) }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@objc public func listRecordings() -> [URL] {
|
|
232
|
+
let fm = FileManager.default
|
|
233
|
+
guard let contents = try? fm.contentsOfDirectory(
|
|
234
|
+
at: recordingsDirectory,
|
|
235
|
+
includingPropertiesForKeys: [.contentModificationDateKey],
|
|
236
|
+
options: [.skipsHiddenFiles]
|
|
237
|
+
) else {
|
|
238
|
+
return []
|
|
239
|
+
}
|
|
240
|
+
return contents.sorted { lhs, rhs in
|
|
241
|
+
let lDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
|
242
|
+
let rDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
|
243
|
+
return lDate > rDate
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// MARK: - PipelineHost
|
|
248
|
+
|
|
249
|
+
func seedOriginNs(_ timestampNs: UInt64) -> UInt64 {
|
|
250
|
+
if let existing = recordingStartHostTimeNs { return existing }
|
|
251
|
+
recordingStartHostTimeNs = timestampNs
|
|
252
|
+
return timestampNs
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func onTrackAdded() {
|
|
256
|
+
pendingPipelines = max(0, pendingPipelines - 1)
|
|
257
|
+
maybeStartSession()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func onFatalError(_ error: NSError) {
|
|
261
|
+
fireTerminalCompletion(url: nil, error: error)
|
|
262
|
+
cleanupAfterFailure()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// MARK: - Internal helpers
|
|
266
|
+
|
|
267
|
+
/// Starts the writer once every active pipeline has reported its input
|
|
268
|
+
/// added. Calling `startWriting()` / `startSession(.zero)` before all
|
|
269
|
+
/// inputs are present would leave un-added tracks orphaned; the gate
|
|
270
|
+
/// is load-bearing.
|
|
271
|
+
private func maybeStartSession() {
|
|
272
|
+
guard let writer = assetWriter else { return }
|
|
273
|
+
if pendingPipelines > 0 { return }
|
|
274
|
+
if writer.status != .unknown { return }
|
|
275
|
+
|
|
276
|
+
guard writer.startWriting() else {
|
|
277
|
+
let err = (writer.error as NSError?) ?? makeRecorderError("start_failed", code: 3)
|
|
278
|
+
fireTerminalCompletion(url: nil, error: err)
|
|
279
|
+
cleanupAfterFailure()
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
// The session origin is `.zero`; PTS values are computed relative
|
|
283
|
+
// to `recordingStartHostTimeNs` before being passed to
|
|
284
|
+
// `VTCompressionSessionEncodeFrame`, so the first encoded sample
|
|
285
|
+
// already carries pts=0.
|
|
286
|
+
writer.startSession(atSourceTime: .zero)
|
|
287
|
+
NSLog("[TracksRecorder] writer.startWriting() + startSession(.zero)")
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private func fireTerminalCompletion(url: URL?, error: NSError?) {
|
|
291
|
+
guard !isCompleted else { return }
|
|
292
|
+
isCompleted = true
|
|
293
|
+
let cb = recordingCompletion
|
|
294
|
+
recordingCompletion = nil
|
|
295
|
+
DispatchQueue.main.async { cb?(url, error) }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private func cleanupAfterFailure() {
|
|
299
|
+
videoPipeline?.detachSink()
|
|
300
|
+
audioPipeline?.detachSink()
|
|
301
|
+
autoStopTimer?.cancel()
|
|
302
|
+
autoStopTimer = nil
|
|
303
|
+
resetTransientState()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private func cleanupAfterStop() {
|
|
307
|
+
resetTransientState()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Resets every transient field to its initial value. Single source of
|
|
311
|
+
/// truth for "the manager is between recordings". Does NOT release
|
|
312
|
+
/// native resources — the caller must have already torn down the
|
|
313
|
+
/// pipelines and the writer.
|
|
314
|
+
private func resetTransientState() {
|
|
315
|
+
videoPipeline = nil
|
|
316
|
+
audioPipeline = nil
|
|
317
|
+
assetWriter = nil
|
|
318
|
+
outputURL = nil
|
|
319
|
+
recordingCompletion = nil
|
|
320
|
+
isCompleted = false
|
|
321
|
+
isRecording = false
|
|
322
|
+
pendingPipelines = 0
|
|
323
|
+
recordingStartHostTimeNs = nil
|
|
324
|
+
autoStopTimer?.cancel()
|
|
325
|
+
autoStopTimer = nil
|
|
326
|
+
}
|
|
327
|
+
}
|