@stream-io/video-react-native-sdk 1.38.2 → 1.39.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 +1629 -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/components/Participant/ParticipantView/ParticipantLabel.js +4 -3
- package/dist/commonjs/components/Participant/ParticipantView/ParticipantLabel.js.map +1 -1
- 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/components/Participant/ParticipantView/ParticipantLabel.js +5 -4
- package/dist/module/components/Participant/ParticipantView/ParticipantLabel.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/components/Participant/ParticipantView/ParticipantLabel.d.ts.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 +7 -6
- package/src/components/Participant/ParticipantView/ParticipantLabel.tsx +5 -3
- 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,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
|
+
}
|
|
@@ -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.39.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/",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"cpp",
|
|
33
33
|
"stream-video-react-native.podspec",
|
|
34
34
|
"package.json",
|
|
35
|
+
"CHANGELOG.md",
|
|
35
36
|
"app.plugin.js",
|
|
36
37
|
"expo-config-plugin/README.md",
|
|
37
38
|
"expo-config-plugin/static/**/*",
|
|
@@ -50,8 +51,8 @@
|
|
|
50
51
|
"!**/.*"
|
|
51
52
|
],
|
|
52
53
|
"dependencies": {
|
|
53
|
-
"@stream-io/video-client": "1.
|
|
54
|
-
"@stream-io/video-react-bindings": "1.
|
|
54
|
+
"@stream-io/video-client": "1.54.1-beta.0",
|
|
55
|
+
"@stream-io/video-react-bindings": "1.17.0",
|
|
55
56
|
"intl-pluralrules": "2.0.1",
|
|
56
57
|
"react-native-url-polyfill": "^3.0.0",
|
|
57
58
|
"rxjs": "~7.8.2",
|
|
@@ -116,10 +117,10 @@
|
|
|
116
117
|
"@react-native-firebase/messaging": "^24.0.0",
|
|
117
118
|
"@react-native/babel-preset": "0.85.3",
|
|
118
119
|
"@react-native/metro-config": "0.85.3",
|
|
119
|
-
"@stream-io/noise-cancellation-react-native": "
|
|
120
|
-
"@stream-io/react-native-callingx": "
|
|
120
|
+
"@stream-io/noise-cancellation-react-native": "0.8.0",
|
|
121
|
+
"@stream-io/react-native-callingx": "0.5.1",
|
|
121
122
|
"@stream-io/react-native-webrtc": "145.0.0",
|
|
122
|
-
"@stream-io/video-filters-react-native": "
|
|
123
|
+
"@stream-io/video-filters-react-native": "0.13.0",
|
|
123
124
|
"@testing-library/jest-native": "^5.4.3",
|
|
124
125
|
"@testing-library/react-native": "13.3.3",
|
|
125
126
|
"@tsconfig/node18": "^18.2.6",
|