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