@stream-io/video-react-native-sdk 1.36.2 → 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/CHANGELOG.md +15 -0
- 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 +18 -38
- package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/commonjs/utils/push/internal/ios.js +4 -3
- package/dist/commonjs/utils/push/internal/ios.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 +19 -39
- package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/module/utils/push/internal/ios.js +4 -3
- package/dist/module/utils/push/internal/ios.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/utils/internal/callingx/callingx.d.ts.map +1 -1
- package/dist/typescript/utils/push/internal/ios.d.ts.map +1 -1
- package/dist/typescript/version.d.ts +1 -1
- package/dist/typescript/version.d.ts.map +1 -1
- package/expo-config-plugin/dist/withAppDelegate.js +14 -177
- package/ios/RTCViewPip.swift +6 -6
- package/ios/RTCViewPipManager.swift +47 -10
- package/ios/StreamInCallManager.swift +2 -6
- package/ios/StreamVideoReactNative-Bridging-Header.h +2 -0
- package/ios/StreamVideoReactNative.h +5 -18
- package/ios/StreamVideoReactNative.m +83 -296
- 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 +19 -44
- package/src/utils/push/internal/ios.ts +4 -3
- package/src/version.ts +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright © 2026 Stream.io Inc. All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import AVFoundation
|
|
6
|
+
import CoreMedia
|
|
7
|
+
import CoreVideo
|
|
8
|
+
import Foundation
|
|
9
|
+
import WebRTC
|
|
10
|
+
|
|
11
|
+
/// Video pipeline owned by `TracksRecorderManager`. Encapsulates the H.264
|
|
12
|
+
/// video path:
|
|
13
|
+
/// - the `RecorderVideoSink` attached to the source `RTCVideoTrack`,
|
|
14
|
+
/// - an `AVAssetWriterInput` configured for H.264 encoding (codec, bitrate,
|
|
15
|
+
/// profile, key-frame interval) paired with an
|
|
16
|
+
/// `AVAssetWriterInputPixelBufferAdaptor` — AVFoundation owns the encoder
|
|
17
|
+
/// and picks hardware/software automatically. No manual VT session.
|
|
18
|
+
/// - per-recording counters / PTS range surfaced via `logSummary` at stop.
|
|
19
|
+
///
|
|
20
|
+
/// All state mutation runs on the host's serial queue. Frames arrive on the
|
|
21
|
+
/// WebRTC sink thread and re-dispatch onto `host.queue` before touching
|
|
22
|
+
/// pipeline state.
|
|
23
|
+
internal final class VideoPipeline {
|
|
24
|
+
|
|
25
|
+
private static let bitRate: NSNumber = NSNumber(value: 1_000_000)
|
|
26
|
+
|
|
27
|
+
private weak var host: PipelineHost?
|
|
28
|
+
private let videoTrack: RTCVideoTrack
|
|
29
|
+
private let targetWidth: Int32
|
|
30
|
+
private let targetHeight: Int32
|
|
31
|
+
|
|
32
|
+
private var sink: RecorderVideoSink?
|
|
33
|
+
private var videoInput: AVAssetWriterInput?
|
|
34
|
+
private var pixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor?
|
|
35
|
+
private var inputAdded = false
|
|
36
|
+
|
|
37
|
+
// Diagnostic counters + PTS range, surfaced via [logSummary] at stop.
|
|
38
|
+
private var framesReceived = 0
|
|
39
|
+
private var samplesAppended = 0
|
|
40
|
+
private var frameAppendFailures = 0
|
|
41
|
+
private var framesDropped = 0
|
|
42
|
+
private var firstSamplePtsUs: Int64 = -1
|
|
43
|
+
private var lastSamplePtsUs: Int64 = -1
|
|
44
|
+
|
|
45
|
+
// MARK: - Init
|
|
46
|
+
|
|
47
|
+
init(
|
|
48
|
+
host: PipelineHost,
|
|
49
|
+
videoTrack: RTCVideoTrack,
|
|
50
|
+
targetWidth: Int32 = 0,
|
|
51
|
+
targetHeight: Int32 = 0
|
|
52
|
+
) {
|
|
53
|
+
self.host = host
|
|
54
|
+
self.videoTrack = videoTrack
|
|
55
|
+
self.targetWidth = targetWidth
|
|
56
|
+
self.targetHeight = targetHeight
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// MARK: - Public API
|
|
60
|
+
|
|
61
|
+
/// Attach the sink to the source track. Future frames post to
|
|
62
|
+
/// `host.queue` and run through the per-frame handler.
|
|
63
|
+
func start() {
|
|
64
|
+
let sink = RecorderVideoSink { [weak self] pixelBuffer, width, height, timestampNs in
|
|
65
|
+
self?.handleVideoFrame(
|
|
66
|
+
pixelBuffer: pixelBuffer,
|
|
67
|
+
width: width,
|
|
68
|
+
height: height,
|
|
69
|
+
timestampNs: timestampNs
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
self.sink = sink
|
|
73
|
+
videoTrack.add(sink)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// On-queue. Remove the sink from the source track so no more frames
|
|
77
|
+
/// arrive. Idempotent.
|
|
78
|
+
func detachSink() {
|
|
79
|
+
if let sink = sink {
|
|
80
|
+
videoTrack.remove(sink)
|
|
81
|
+
}
|
|
82
|
+
sink = nil
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// On-queue. Marks the asset-writer input as finished so the writer
|
|
86
|
+
/// can finalise. AVFoundation's `finishWriting` flushes any pending
|
|
87
|
+
/// encoded samples internally; no separate drain step is required.
|
|
88
|
+
func markInputAsFinished() {
|
|
89
|
+
videoInput?.markAsFinished()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// On-queue. Logs the pipeline's diagnostic summary — call once at the
|
|
93
|
+
/// end of a recording.
|
|
94
|
+
func logSummary() {
|
|
95
|
+
let durationMs: Int64
|
|
96
|
+
if firstSamplePtsUs >= 0 && lastSamplePtsUs >= firstSamplePtsUs {
|
|
97
|
+
durationMs = (lastSamplePtsUs - firstSamplePtsUs) / 1000
|
|
98
|
+
} else {
|
|
99
|
+
durationMs = -1
|
|
100
|
+
}
|
|
101
|
+
let writerStatus = host?.assetWriter?.status.rawValue ?? -1
|
|
102
|
+
let writerErr = (host?.assetWriter?.error as NSError?)?.localizedDescription ?? "nil"
|
|
103
|
+
NSLog(
|
|
104
|
+
"[TracksRecorder.Video] summary received=%d appended=%d appendFailures=%d dropped=%d firstPtsUs=%lld lastPtsUs=%lld durationMs=%lld writerStatus=%ld writerErr=%@",
|
|
105
|
+
framesReceived,
|
|
106
|
+
samplesAppended,
|
|
107
|
+
frameAppendFailures,
|
|
108
|
+
framesDropped,
|
|
109
|
+
firstSamplePtsUs,
|
|
110
|
+
lastSamplePtsUs,
|
|
111
|
+
durationMs,
|
|
112
|
+
writerStatus,
|
|
113
|
+
writerErr
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: - Sink → queue bridge
|
|
118
|
+
|
|
119
|
+
private func handleVideoFrame(
|
|
120
|
+
pixelBuffer: CVPixelBuffer,
|
|
121
|
+
width: Int32,
|
|
122
|
+
height: Int32,
|
|
123
|
+
timestampNs: Int64
|
|
124
|
+
) {
|
|
125
|
+
guard let host = host else { return }
|
|
126
|
+
|
|
127
|
+
// Pixel buffer is borrowed from the sink — it must be retained
|
|
128
|
+
// across the queue hop. The closure capture pins it for the
|
|
129
|
+
// duration of the async block (ARC retains it as part of the
|
|
130
|
+
// closure's captured state).
|
|
131
|
+
host.queue.async { [weak self, pixelBuffer] in
|
|
132
|
+
self?.handleVideoFrameOnQueue(
|
|
133
|
+
pixelBuffer: pixelBuffer,
|
|
134
|
+
width: width,
|
|
135
|
+
height: height,
|
|
136
|
+
timestampNs: timestampNs
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private func handleVideoFrameOnQueue(
|
|
142
|
+
pixelBuffer: CVPixelBuffer,
|
|
143
|
+
width: Int32,
|
|
144
|
+
height: Int32,
|
|
145
|
+
timestampNs: Int64
|
|
146
|
+
) {
|
|
147
|
+
guard let host = host, host.isRecording, let writer = host.assetWriter else { return }
|
|
148
|
+
|
|
149
|
+
framesReceived += 1
|
|
150
|
+
|
|
151
|
+
// Lazy-create the writer's video input on the first frame.
|
|
152
|
+
// Prefer the publisher's max video dimensions so the encoder
|
|
153
|
+
// is sized for the highest layer the SFU might ever forward;
|
|
154
|
+
// fall back to the first frame's actual dimensions when no
|
|
155
|
+
// target is supplied.
|
|
156
|
+
if videoInput == nil {
|
|
157
|
+
let actualW = Int32(CVPixelBufferGetWidth(pixelBuffer))
|
|
158
|
+
let actualH = Int32(CVPixelBufferGetHeight(pixelBuffer))
|
|
159
|
+
let (encW, encH) = resolveEncoderDimensions(
|
|
160
|
+
targetW: targetWidth,
|
|
161
|
+
targetH: targetHeight,
|
|
162
|
+
frameW: actualW,
|
|
163
|
+
frameH: actualH
|
|
164
|
+
)
|
|
165
|
+
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
|
|
166
|
+
configureVideoInput(
|
|
167
|
+
width: encW,
|
|
168
|
+
height: encH,
|
|
169
|
+
pixelFormat: pixelFormat,
|
|
170
|
+
writer: writer
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let pts = presentationTime(host: host, timestampNs: UInt64(bitPattern: timestampNs))
|
|
175
|
+
|
|
176
|
+
guard
|
|
177
|
+
writer.status == .writing,
|
|
178
|
+
let input = videoInput,
|
|
179
|
+
let adaptor = pixelBufferAdaptor
|
|
180
|
+
else {
|
|
181
|
+
if writer.status == .failed {
|
|
182
|
+
let err = writer.error as NSError?
|
|
183
|
+
NSLog("[TracksRecorder.Video] writer FAILED in frame handler: domain=%@ code=%ld desc=%@",
|
|
184
|
+
err?.domain ?? "nil", err?.code ?? 0,
|
|
185
|
+
err?.localizedDescription ?? "nil")
|
|
186
|
+
}
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if !input.isReadyForMoreMediaData {
|
|
191
|
+
framesDropped += 1
|
|
192
|
+
if framesDropped % 30 == 1 {
|
|
193
|
+
NSLog("[TracksRecorder.Video] input not ready, dropped=%d", framesDropped)
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let appended = adaptor.append(pixelBuffer, withPresentationTime: pts)
|
|
199
|
+
if appended {
|
|
200
|
+
samplesAppended += 1
|
|
201
|
+
let ptsUs = Int64(CMTimeGetSeconds(pts) * 1_000_000)
|
|
202
|
+
if firstSamplePtsUs < 0 || ptsUs < firstSamplePtsUs {
|
|
203
|
+
firstSamplePtsUs = ptsUs
|
|
204
|
+
}
|
|
205
|
+
if ptsUs > lastSamplePtsUs {
|
|
206
|
+
lastSamplePtsUs = ptsUs
|
|
207
|
+
}
|
|
208
|
+
if samplesAppended == 1 {
|
|
209
|
+
NSLog("[TracksRecorder.Video] first frame appended OK pts=%lldus", ptsUs)
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
// `adaptor.append` returns false without a direct reason —
|
|
213
|
+
// inspect `writer.error` and the input's ready flag for
|
|
214
|
+
// diagnostics.
|
|
215
|
+
frameAppendFailures += 1
|
|
216
|
+
let writerErr = writer.error as NSError?
|
|
217
|
+
NSLog(
|
|
218
|
+
"[TracksRecorder.Video] adaptor.append FAILED #%d writerStatus=%ld writerErr domain=%@ code=%ld desc=%@ inputReady=%@",
|
|
219
|
+
frameAppendFailures,
|
|
220
|
+
writer.status.rawValue,
|
|
221
|
+
writerErr?.domain ?? "nil",
|
|
222
|
+
writerErr?.code ?? 0,
|
|
223
|
+
writerErr?.localizedDescription ?? "nil",
|
|
224
|
+
input.isReadyForMoreMediaData ? "YES" : "NO"
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// MARK: - Asset writer input setup
|
|
230
|
+
|
|
231
|
+
/// Picks the encoder dimensions, preferring the caller-supplied
|
|
232
|
+
/// target but oriented to match the frame buffer. Publish options
|
|
233
|
+
/// are expressed in WebRTC's canonical landscape form; the buffer
|
|
234
|
+
/// may be portrait — swap target axes when they disagree. Falls
|
|
235
|
+
/// back to the frame's own dimensions if no target was supplied.
|
|
236
|
+
private func resolveEncoderDimensions(
|
|
237
|
+
targetW: Int32,
|
|
238
|
+
targetH: Int32,
|
|
239
|
+
frameW: Int32,
|
|
240
|
+
frameH: Int32
|
|
241
|
+
) -> (Int32, Int32) {
|
|
242
|
+
guard targetW > 0 && targetH > 0 else { return (frameW, frameH) }
|
|
243
|
+
let framePortrait = frameH > frameW
|
|
244
|
+
let targetPortrait = targetH > targetW
|
|
245
|
+
if framePortrait == targetPortrait {
|
|
246
|
+
return (targetW, targetH)
|
|
247
|
+
}
|
|
248
|
+
return (targetH, targetW)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// Builds the H.264 `AVAssetWriterInput` and its pixel-buffer
|
|
252
|
+
/// adaptor. AVFoundation owns the encoder and falls back from
|
|
253
|
+
/// hardware to software if the H.264 pool is contended.
|
|
254
|
+
private func configureVideoInput(
|
|
255
|
+
width: Int32,
|
|
256
|
+
height: Int32,
|
|
257
|
+
pixelFormat: OSType,
|
|
258
|
+
writer: AVAssetWriter
|
|
259
|
+
) {
|
|
260
|
+
// `AVVideoScalingModeResizeAspect` lets AVFoundation scale
|
|
261
|
+
// incoming pixel buffers to the encoder's output dimensions
|
|
262
|
+
// while preserving aspect ratio.
|
|
263
|
+
let settings: [String: Any] = [
|
|
264
|
+
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
265
|
+
AVVideoWidthKey: width,
|
|
266
|
+
AVVideoHeightKey: height,
|
|
267
|
+
AVVideoScalingModeKey: AVVideoScalingModeResizeAspect,
|
|
268
|
+
AVVideoCompressionPropertiesKey: [
|
|
269
|
+
AVVideoAverageBitRateKey: VideoPipeline.bitRate,
|
|
270
|
+
],
|
|
271
|
+
]
|
|
272
|
+
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
|
273
|
+
input.expectsMediaDataInRealTime = true
|
|
274
|
+
|
|
275
|
+
// Omit width/height in source attributes so the adaptor
|
|
276
|
+
// accepts buffers at any layer resolution the SFU forwards.
|
|
277
|
+
let pbAttributes: [String: Any] = [
|
|
278
|
+
kCVPixelBufferPixelFormatTypeKey as String: pixelFormat,
|
|
279
|
+
]
|
|
280
|
+
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
|
281
|
+
assetWriterInput: input,
|
|
282
|
+
sourcePixelBufferAttributes: pbAttributes
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
guard writer.canAdd(input) else {
|
|
286
|
+
NSLog("[TracksRecorder.Video] writer cannot add encoding video input")
|
|
287
|
+
host?.onFatalError(makeRecorderError("video_input_add_failed", code: 4))
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
writer.add(input)
|
|
292
|
+
videoInput = input
|
|
293
|
+
pixelBufferAdaptor = adaptor
|
|
294
|
+
inputAdded = true
|
|
295
|
+
host?.onTrackAdded()
|
|
296
|
+
}
|
|
297
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stream-io/video-react-native-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.1-beta.0",
|
|
4
4
|
"description": "Stream Video SDK for React Native",
|
|
5
5
|
"author": "https://getstream.io",
|
|
6
6
|
"homepage": "https://getstream.io/video/docs/react-native/",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"!**/.*"
|
|
51
51
|
],
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@stream-io/video-client": "1.52.0",
|
|
54
|
-
"@stream-io/video-react-bindings": "1.16.
|
|
53
|
+
"@stream-io/video-client": "1.52.1-beta.0",
|
|
54
|
+
"@stream-io/video-react-bindings": "1.16.3-beta.0",
|
|
55
55
|
"intl-pluralrules": "2.0.1",
|
|
56
56
|
"react-native-url-polyfill": "^3.0.0",
|
|
57
57
|
"rxjs": "~7.8.2",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"@react-native-firebase/messaging": ">=17.5.0",
|
|
64
64
|
"@stream-io/noise-cancellation-react-native": ">=0.1.0",
|
|
65
65
|
"@stream-io/react-native-callingx": ">=0.1.0",
|
|
66
|
-
"@stream-io/react-native-webrtc": ">=137.2.
|
|
66
|
+
"@stream-io/react-native-webrtc": ">=137.2.2",
|
|
67
67
|
"@stream-io/video-filters-react-native": ">=0.1.0",
|
|
68
68
|
"expo": ">=47.0.0",
|
|
69
69
|
"expo-build-properties": "*",
|
|
@@ -116,10 +116,10 @@
|
|
|
116
116
|
"@react-native-firebase/messaging": "^24.0.0",
|
|
117
117
|
"@react-native/babel-preset": "0.85.3",
|
|
118
118
|
"@react-native/metro-config": "0.85.3",
|
|
119
|
-
"@stream-io/noise-cancellation-react-native": "
|
|
120
|
-
"@stream-io/react-native-callingx": "
|
|
121
|
-
"@stream-io/react-native-webrtc": "137.2.0",
|
|
122
|
-
"@stream-io/video-filters-react-native": "
|
|
119
|
+
"@stream-io/noise-cancellation-react-native": "0.7.0",
|
|
120
|
+
"@stream-io/react-native-callingx": "0.4.0",
|
|
121
|
+
"@stream-io/react-native-webrtc": "137.2.2-alpha.0",
|
|
122
|
+
"@stream-io/video-filters-react-native": "0.12.4",
|
|
123
123
|
"@testing-library/jest-native": "^5.4.3",
|
|
124
124
|
"@testing-library/react-native": "13.3.3",
|
|
125
125
|
"@tsconfig/node18": "^18.2.6",
|
package/src/hooks/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from './useIsInPiPMode';
|
|
|
7
7
|
export * from './useAutoEnterPiPEffect';
|
|
8
8
|
export * from './useScreenShareButton';
|
|
9
9
|
export * from './useScreenShareAudioMixing';
|
|
10
|
+
export * from './useLoopbackRecording';
|
|
10
11
|
export * from './useTrackDimensions';
|
|
11
12
|
export * from './useScreenshot';
|
|
12
13
|
export * from './useModeration';
|