@stream-io/video-react-native-sdk 1.37.1-beta.0 → 1.38.1

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 (84) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +0 -81
  3. package/dist/commonjs/hooks/index.js +0 -11
  4. package/dist/commonjs/hooks/index.js.map +1 -1
  5. package/dist/commonjs/modules/call-manager/CallManager.js +13 -0
  6. package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
  7. package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js +37 -0
  8. package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js.map +1 -0
  9. package/dist/commonjs/providers/StreamCall/index.js +2 -1
  10. package/dist/commonjs/providers/StreamCall/index.js.map +1 -1
  11. package/dist/commonjs/utils/internal/callingx/callingx.js +2 -2
  12. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  13. package/dist/commonjs/utils/internal/registerSDKGlobals.js +16 -0
  14. package/dist/commonjs/utils/internal/registerSDKGlobals.js.map +1 -1
  15. package/dist/commonjs/utils/push/internal/ios.js +5 -0
  16. package/dist/commonjs/utils/push/internal/ios.js.map +1 -1
  17. package/dist/commonjs/version.js +1 -1
  18. package/dist/commonjs/version.js.map +1 -1
  19. package/dist/module/hooks/index.js +0 -1
  20. package/dist/module/hooks/index.js.map +1 -1
  21. package/dist/module/modules/call-manager/CallManager.js +13 -0
  22. package/dist/module/modules/call-manager/CallManager.js.map +1 -1
  23. package/dist/module/providers/StreamCall/AudioInterruptionTracer.js +30 -0
  24. package/dist/module/providers/StreamCall/AudioInterruptionTracer.js.map +1 -0
  25. package/dist/module/providers/StreamCall/index.js +2 -1
  26. package/dist/module/providers/StreamCall/index.js.map +1 -1
  27. package/dist/module/utils/internal/callingx/callingx.js +2 -2
  28. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  29. package/dist/module/utils/internal/registerSDKGlobals.js +17 -1
  30. package/dist/module/utils/internal/registerSDKGlobals.js.map +1 -1
  31. package/dist/module/utils/push/internal/ios.js +5 -0
  32. package/dist/module/utils/push/internal/ios.js.map +1 -1
  33. package/dist/module/version.js +1 -1
  34. package/dist/module/version.js.map +1 -1
  35. package/dist/typescript/hooks/index.d.ts +0 -1
  36. package/dist/typescript/hooks/index.d.ts.map +1 -1
  37. package/dist/typescript/modules/call-manager/CallManager.d.ts +8 -1
  38. package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
  39. package/dist/typescript/modules/call-manager/types.d.ts +6 -0
  40. package/dist/typescript/modules/call-manager/types.d.ts.map +1 -1
  41. package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts +5 -0
  42. package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts.map +1 -0
  43. package/dist/typescript/providers/StreamCall/index.d.ts.map +1 -1
  44. package/dist/typescript/utils/internal/registerSDKGlobals.d.ts.map +1 -1
  45. package/dist/typescript/utils/push/internal/ios.d.ts.map +1 -1
  46. package/dist/typescript/version.d.ts +1 -1
  47. package/dist/typescript/version.d.ts.map +1 -1
  48. package/ios/PictureInPicture/PictureInPictureAvatarView.swift +3 -1
  49. package/ios/PictureInPicture/StreamBufferTransformer.swift +13 -4
  50. package/ios/PictureInPicture/StreamPictureInPictureVideoRenderer.swift +79 -71
  51. package/ios/PictureInPicture/StreamRTCYUVBuffer.swift +20 -16
  52. package/ios/StreamInCallManager.swift +256 -81
  53. package/ios/StreamVideoReactNative-Bridging-Header.h +0 -2
  54. package/ios/StreamVideoReactNative.m +0 -81
  55. package/package.json +11 -11
  56. package/src/hooks/index.ts +0 -1
  57. package/src/modules/call-manager/CallManager.ts +25 -1
  58. package/src/modules/call-manager/types.ts +7 -0
  59. package/src/providers/StreamCall/AudioInterruptionTracer.tsx +51 -0
  60. package/src/providers/StreamCall/index.tsx +2 -0
  61. package/src/utils/internal/callingx/callingx.ts +2 -2
  62. package/src/utils/internal/registerSDKGlobals.ts +23 -1
  63. package/src/utils/push/internal/ios.ts +5 -0
  64. package/src/version.ts +1 -1
  65. package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +0 -436
  66. package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +0 -17
  67. package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +0 -36
  68. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +0 -60
  69. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +0 -31
  70. package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +0 -329
  71. package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +0 -472
  72. package/dist/commonjs/hooks/useLoopbackRecording.js +0 -243
  73. package/dist/commonjs/hooks/useLoopbackRecording.js.map +0 -1
  74. package/dist/module/hooks/useLoopbackRecording.js +0 -238
  75. package/dist/module/hooks/useLoopbackRecording.js.map +0 -1
  76. package/dist/typescript/hooks/useLoopbackRecording.d.ts +0 -85
  77. package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +0 -1
  78. package/ios/TracksRecorder/AudioPipeline.swift +0 -270
  79. package/ios/TracksRecorder/PipelineHost.swift +0 -56
  80. package/ios/TracksRecorder/RecorderAudioRenderTap.swift +0 -154
  81. package/ios/TracksRecorder/RecorderVideoSink.swift +0 -137
  82. package/ios/TracksRecorder/TracksRecorderManager.swift +0 -327
  83. package/ios/TracksRecorder/VideoPipeline.swift +0 -297
  84. package/src/hooks/useLoopbackRecording.ts +0 -438
@@ -1,327 +0,0 @@
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
- }
@@ -1,297 +0,0 @@
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
- }