@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +81 -0
  3. package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +436 -0
  4. package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +17 -0
  5. package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +36 -0
  6. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +60 -0
  7. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +31 -0
  8. package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +329 -0
  9. package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +472 -0
  10. package/dist/commonjs/hooks/index.js +11 -0
  11. package/dist/commonjs/hooks/index.js.map +1 -1
  12. package/dist/commonjs/hooks/useLoopbackRecording.js +243 -0
  13. package/dist/commonjs/hooks/useLoopbackRecording.js.map +1 -0
  14. package/dist/commonjs/utils/internal/callingx/callingx.js +18 -38
  15. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  16. package/dist/commonjs/utils/push/internal/ios.js +4 -3
  17. package/dist/commonjs/utils/push/internal/ios.js.map +1 -1
  18. package/dist/commonjs/version.js +1 -1
  19. package/dist/commonjs/version.js.map +1 -1
  20. package/dist/module/hooks/index.js +1 -0
  21. package/dist/module/hooks/index.js.map +1 -1
  22. package/dist/module/hooks/useLoopbackRecording.js +238 -0
  23. package/dist/module/hooks/useLoopbackRecording.js.map +1 -0
  24. package/dist/module/utils/internal/callingx/callingx.js +19 -39
  25. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  26. package/dist/module/utils/push/internal/ios.js +4 -3
  27. package/dist/module/utils/push/internal/ios.js.map +1 -1
  28. package/dist/module/version.js +1 -1
  29. package/dist/module/version.js.map +1 -1
  30. package/dist/typescript/hooks/index.d.ts +1 -0
  31. package/dist/typescript/hooks/index.d.ts.map +1 -1
  32. package/dist/typescript/hooks/useLoopbackRecording.d.ts +85 -0
  33. package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +1 -0
  34. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  35. package/dist/typescript/utils/push/internal/ios.d.ts.map +1 -1
  36. package/dist/typescript/version.d.ts +1 -1
  37. package/dist/typescript/version.d.ts.map +1 -1
  38. package/expo-config-plugin/dist/withAppDelegate.js +14 -177
  39. package/ios/RTCViewPip.swift +6 -6
  40. package/ios/RTCViewPipManager.swift +47 -10
  41. package/ios/StreamInCallManager.swift +2 -6
  42. package/ios/StreamVideoReactNative-Bridging-Header.h +2 -0
  43. package/ios/StreamVideoReactNative.h +5 -18
  44. package/ios/StreamVideoReactNative.m +83 -296
  45. package/ios/TracksRecorder/AudioPipeline.swift +270 -0
  46. package/ios/TracksRecorder/PipelineHost.swift +56 -0
  47. package/ios/TracksRecorder/RecorderAudioRenderTap.swift +154 -0
  48. package/ios/TracksRecorder/RecorderVideoSink.swift +137 -0
  49. package/ios/TracksRecorder/TracksRecorderManager.swift +327 -0
  50. package/ios/TracksRecorder/VideoPipeline.swift +297 -0
  51. package/package.json +8 -8
  52. package/src/hooks/index.ts +1 -0
  53. package/src/hooks/useLoopbackRecording.ts +438 -0
  54. package/src/utils/internal/callingx/callingx.ts +19 -44
  55. package/src/utils/push/internal/ios.ts +4 -3
  56. 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.36.2",
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.2",
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.0",
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": "^0.7.0",
120
- "@stream-io/react-native-callingx": "^0.3.1",
121
- "@stream-io/react-native-webrtc": "137.2.0",
122
- "@stream-io/video-filters-react-native": "^0.12.4",
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",
@@ -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';