node-mac-recorder 2.20.17 → 2.21.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.
- package/AUDIO_CAPTURE.md +84 -0
- package/CAMERA_CAPTURE.md +77 -0
- package/MACOS_PERMISSIONS.md +58 -0
- package/README.md +76 -3
- package/binding.gyp +3 -2
- package/index.js +342 -28
- package/package.json +1 -1
- package/scripts/test-multi-capture.js +103 -0
- package/src/audio_recorder.mm +372 -0
- package/src/camera_recorder.mm +754 -0
- package/src/cursor_tracker.mm +12 -63
- package/src/mac_recorder.mm +367 -72
- package/src/screen_capture_kit.h +2 -2
- package/src/screen_capture_kit.mm +472 -92
- package/src/audio_capture.mm +0 -41
|
@@ -1,17 +1,123 @@
|
|
|
1
1
|
#import "screen_capture_kit.h"
|
|
2
2
|
#import "logging.h"
|
|
3
|
+
#import <AVFoundation/AVFoundation.h>
|
|
4
|
+
#import <CoreVideo/CoreVideo.h>
|
|
5
|
+
#import <CoreMedia/CoreMedia.h>
|
|
6
|
+
#import <AudioToolbox/AudioToolbox.h>
|
|
3
7
|
|
|
4
8
|
// Pure ScreenCaptureKit implementation - NO AVFoundation
|
|
5
9
|
static SCStream * API_AVAILABLE(macos(12.3)) g_stream = nil;
|
|
6
|
-
static SCRecordingOutput * API_AVAILABLE(macos(15.0)) g_recordingOutput = nil;
|
|
7
10
|
static id<SCStreamDelegate> API_AVAILABLE(macos(12.3)) g_streamDelegate = nil;
|
|
8
11
|
static BOOL g_isRecording = NO;
|
|
9
12
|
static BOOL g_isCleaningUp = NO; // Prevent recursive cleanup
|
|
10
13
|
static NSString *g_outputPath = nil;
|
|
11
14
|
|
|
15
|
+
static dispatch_queue_t g_videoQueue = nil;
|
|
16
|
+
static dispatch_queue_t g_audioQueue = nil;
|
|
17
|
+
static id g_videoStreamOutput = nil;
|
|
18
|
+
static id g_audioStreamOutput = nil;
|
|
19
|
+
|
|
20
|
+
static AVAssetWriter *g_videoWriter = nil;
|
|
21
|
+
static AVAssetWriterInput *g_videoInput = nil;
|
|
22
|
+
static CFTypeRef g_pixelBufferAdaptorRef = NULL;
|
|
23
|
+
static CMTime g_videoStartTime = kCMTimeInvalid;
|
|
24
|
+
static BOOL g_videoWriterStarted = NO;
|
|
25
|
+
|
|
26
|
+
static BOOL g_shouldCaptureAudio = NO;
|
|
27
|
+
static NSString *g_audioOutputPath = nil;
|
|
28
|
+
static AVAssetWriter *g_audioWriter = nil;
|
|
29
|
+
static AVAssetWriterInput *g_audioInput = nil;
|
|
30
|
+
static CMTime g_audioStartTime = kCMTimeInvalid;
|
|
31
|
+
static BOOL g_audioWriterStarted = NO;
|
|
32
|
+
|
|
33
|
+
static NSInteger g_configuredSampleRate = 48000;
|
|
34
|
+
static NSInteger g_configuredChannelCount = 2;
|
|
35
|
+
|
|
36
|
+
static void CleanupWriters(void);
|
|
37
|
+
static AVAssetWriterInputPixelBufferAdaptor * _Nullable CurrentPixelBufferAdaptor(void) {
|
|
38
|
+
if (!g_pixelBufferAdaptorRef) {
|
|
39
|
+
return nil;
|
|
40
|
+
}
|
|
41
|
+
return (__bridge AVAssetWriterInputPixelBufferAdaptor *)g_pixelBufferAdaptorRef;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static NSString *MRNormalizePath(id value) {
|
|
45
|
+
if (!value || value == (id)kCFNull) {
|
|
46
|
+
return nil;
|
|
47
|
+
}
|
|
48
|
+
if ([value isKindOfClass:[NSString class]]) {
|
|
49
|
+
return (NSString *)value;
|
|
50
|
+
}
|
|
51
|
+
if ([value isKindOfClass:[NSURL class]]) {
|
|
52
|
+
return [(NSURL *)value path];
|
|
53
|
+
}
|
|
54
|
+
if ([value isKindOfClass:[NSArray class]]) {
|
|
55
|
+
for (id entry in (NSArray *)value) {
|
|
56
|
+
NSString *candidate = MRNormalizePath(entry);
|
|
57
|
+
if (candidate.length > 0) {
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return nil;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static void FinishWriter(AVAssetWriter *writer, AVAssetWriterInput *input) {
|
|
66
|
+
if (!writer) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (input) {
|
|
71
|
+
[input markAsFinished];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
|
75
|
+
[writer finishWritingWithCompletionHandler:^{
|
|
76
|
+
dispatch_semaphore_signal(semaphore);
|
|
77
|
+
}];
|
|
78
|
+
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC));
|
|
79
|
+
dispatch_semaphore_wait(semaphore, timeout);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static void CleanupWriters(void) {
|
|
83
|
+
if (g_videoWriter) {
|
|
84
|
+
FinishWriter(g_videoWriter, g_videoInput);
|
|
85
|
+
g_videoWriter = nil;
|
|
86
|
+
g_videoInput = nil;
|
|
87
|
+
if (g_pixelBufferAdaptorRef) {
|
|
88
|
+
CFRelease(g_pixelBufferAdaptorRef);
|
|
89
|
+
g_pixelBufferAdaptorRef = NULL;
|
|
90
|
+
}
|
|
91
|
+
g_videoWriterStarted = NO;
|
|
92
|
+
g_videoStartTime = kCMTimeInvalid;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (g_audioWriter) {
|
|
96
|
+
FinishWriter(g_audioWriter, g_audioInput);
|
|
97
|
+
g_audioWriter = nil;
|
|
98
|
+
g_audioInput = nil;
|
|
99
|
+
g_audioWriterStarted = NO;
|
|
100
|
+
g_audioStartTime = kCMTimeInvalid;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
12
104
|
@interface PureScreenCaptureDelegate : NSObject <SCStreamDelegate>
|
|
13
105
|
@end
|
|
14
106
|
|
|
107
|
+
extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
|
|
108
|
+
if (!g_audioOutputPath) {
|
|
109
|
+
return nil;
|
|
110
|
+
}
|
|
111
|
+
if ([g_audioOutputPath isKindOfClass:[NSArray class]]) {
|
|
112
|
+
id first = [(NSArray *)g_audioOutputPath firstObject];
|
|
113
|
+
if ([first isKindOfClass:[NSString class]]) {
|
|
114
|
+
return first;
|
|
115
|
+
}
|
|
116
|
+
return nil;
|
|
117
|
+
}
|
|
118
|
+
return g_audioOutputPath;
|
|
119
|
+
}
|
|
120
|
+
|
|
15
121
|
@implementation PureScreenCaptureDelegate
|
|
16
122
|
- (void)stream:(SCStream * API_AVAILABLE(macos(12.3)))stream didStopWithError:(NSError *)error API_AVAILABLE(macos(12.3)) {
|
|
17
123
|
MRLog(@"🛑 Pure ScreenCapture stream stopped");
|
|
@@ -39,8 +145,301 @@ static NSString *g_outputPath = nil;
|
|
|
39
145
|
}
|
|
40
146
|
@end
|
|
41
147
|
|
|
148
|
+
@interface ScreenCaptureKitRecorder (Private)
|
|
149
|
+
+ (BOOL)prepareAudioWriterIfNeededWithSampleBuffer:(CMSampleBufferRef)sampleBuffer;
|
|
150
|
+
@end
|
|
151
|
+
|
|
152
|
+
@interface ScreenCaptureVideoOutput : NSObject <SCStreamOutput>
|
|
153
|
+
@end
|
|
154
|
+
|
|
155
|
+
@implementation ScreenCaptureVideoOutput
|
|
156
|
+
- (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type API_AVAILABLE(macos(12.3)) {
|
|
157
|
+
if (!g_isRecording || type != SCStreamOutputTypeScreen) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!CMSampleBufferDataIsReady(sampleBuffer)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!g_videoWriter || !g_videoInput) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
CMTime presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
|
|
170
|
+
|
|
171
|
+
if (!g_videoWriterStarted) {
|
|
172
|
+
if (![g_videoWriter startWriting]) {
|
|
173
|
+
NSLog(@"❌ ScreenCaptureKit video writer failed to start: %@", g_videoWriter.error);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
[g_videoWriter startSessionAtSourceTime:presentationTime];
|
|
177
|
+
g_videoStartTime = presentationTime;
|
|
178
|
+
g_videoWriterStarted = YES;
|
|
179
|
+
MRLog(@"🎞️ Video writer session started @ %.3f", CMTimeGetSeconds(presentationTime));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!g_videoInput.readyForMoreMediaData) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
|
|
187
|
+
if (!pixelBuffer) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
AVAssetWriterInputPixelBufferAdaptor *adaptorCandidate = CurrentPixelBufferAdaptor();
|
|
192
|
+
if ([adaptorCandidate isKindOfClass:[NSArray class]]) {
|
|
193
|
+
id first = [(NSArray *)adaptorCandidate firstObject];
|
|
194
|
+
if ([first isKindOfClass:[AVAssetWriterInputPixelBufferAdaptor class]]) {
|
|
195
|
+
adaptorCandidate = first;
|
|
196
|
+
if (g_pixelBufferAdaptorRef) {
|
|
197
|
+
CFRelease(g_pixelBufferAdaptorRef);
|
|
198
|
+
}
|
|
199
|
+
g_pixelBufferAdaptorRef = CFBridgingRetain(adaptorCandidate);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (![adaptorCandidate isKindOfClass:[AVAssetWriterInputPixelBufferAdaptor class]]) {
|
|
203
|
+
if (adaptorCandidate) {
|
|
204
|
+
MRLog(@"⚠️ Pixel buffer adaptor invalid (%@) – skipping frame", NSStringFromClass([adaptorCandidate class]));
|
|
205
|
+
}
|
|
206
|
+
NSLog(@"❌ Pixel buffer adaptor is nil – cannot append video frames");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
AVAssetWriterInputPixelBufferAdaptor *adaptor = adaptorCandidate;
|
|
211
|
+
BOOL appended = [adaptor appendPixelBuffer:pixelBuffer withPresentationTime:presentationTime];
|
|
212
|
+
if (!appended) {
|
|
213
|
+
NSLog(@"⚠️ Failed appending pixel buffer: %@", g_videoWriter.error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
@end
|
|
217
|
+
|
|
218
|
+
@interface ScreenCaptureAudioOutput : NSObject <SCStreamOutput>
|
|
219
|
+
@end
|
|
220
|
+
|
|
221
|
+
@implementation ScreenCaptureAudioOutput
|
|
222
|
+
- (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type API_AVAILABLE(macos(12.3)) {
|
|
223
|
+
if (!g_isRecording || !g_shouldCaptureAudio) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (@available(macOS 13.0, *)) {
|
|
228
|
+
if (type != SCStreamOutputTypeAudio) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!CMSampleBufferDataIsReady(sampleBuffer)) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (![ScreenCaptureKitRecorder prepareAudioWriterIfNeededWithSampleBuffer:sampleBuffer]) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!g_audioWriter || !g_audioInput) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
CMTime presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
|
|
248
|
+
|
|
249
|
+
if (!g_audioWriterStarted) {
|
|
250
|
+
if (![g_audioWriter startWriting]) {
|
|
251
|
+
NSLog(@"❌ Audio writer failed to start: %@", g_audioWriter.error);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
[g_audioWriter startSessionAtSourceTime:presentationTime];
|
|
255
|
+
g_audioStartTime = presentationTime;
|
|
256
|
+
g_audioWriterStarted = YES;
|
|
257
|
+
MRLog(@"🔊 Audio writer session started @ %.3f", CMTimeGetSeconds(presentationTime));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!g_audioInput.readyForMoreMediaData) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (![g_audioInput appendSampleBuffer:sampleBuffer]) {
|
|
265
|
+
NSLog(@"⚠️ Failed appending audio sample buffer: %@", g_audioWriter.error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
@end
|
|
269
|
+
|
|
42
270
|
@implementation ScreenCaptureKitRecorder
|
|
43
271
|
|
|
272
|
+
+ (BOOL)prepareVideoWriterWithWidth:(NSInteger)width height:(NSInteger)height error:(NSError **)error {
|
|
273
|
+
MRLog(@"🎬 Preparing video writer %ldx%ld", (long)width, (long)height);
|
|
274
|
+
if (!g_outputPath) {
|
|
275
|
+
MRLog(@"❌ Video writer failed: missing output path");
|
|
276
|
+
return NO;
|
|
277
|
+
}
|
|
278
|
+
if (width <= 0 || height <= 0) {
|
|
279
|
+
MRLog(@"❌ Video writer invalid dimensions %ldx%ld", (long)width, (long)height);
|
|
280
|
+
return NO;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
NSURL *outputURL = [NSURL fileURLWithPath:g_outputPath];
|
|
284
|
+
[[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil];
|
|
285
|
+
|
|
286
|
+
g_videoWriter = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeQuickTimeMovie error:error];
|
|
287
|
+
if (!g_videoWriter || (error && *error)) {
|
|
288
|
+
MRLog(@"❌ Failed creating video writer: %@", error && *error ? (*error).localizedDescription : @"unknown");
|
|
289
|
+
return NO;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
NSDictionary *compressionProps = @{
|
|
293
|
+
AVVideoAverageBitRateKey: @(width * height * 6),
|
|
294
|
+
AVVideoMaxKeyFrameIntervalKey: @30
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
NSDictionary *videoSettings = @{
|
|
298
|
+
AVVideoCodecKey: AVVideoCodecTypeH264,
|
|
299
|
+
AVVideoWidthKey: @(width),
|
|
300
|
+
AVVideoHeightKey: @(height),
|
|
301
|
+
AVVideoCompressionPropertiesKey: compressionProps
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
g_videoInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings];
|
|
305
|
+
g_videoInput.expectsMediaDataInRealTime = YES;
|
|
306
|
+
|
|
307
|
+
AVAssetWriterInputPixelBufferAdaptor *pixelAdaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:g_videoInput sourcePixelBufferAttributes:@{
|
|
308
|
+
(NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
|
|
309
|
+
(NSString *)kCVPixelBufferWidthKey: @(width),
|
|
310
|
+
(NSString *)kCVPixelBufferHeightKey: @(height),
|
|
311
|
+
(NSString *)kCVPixelBufferCGImageCompatibilityKey: @YES,
|
|
312
|
+
(NSString *)kCVPixelBufferCGBitmapContextCompatibilityKey: @YES
|
|
313
|
+
}];
|
|
314
|
+
|
|
315
|
+
if (![g_videoWriter canAddInput:g_videoInput]) {
|
|
316
|
+
MRLog(@"❌ Cannot add video input to writer");
|
|
317
|
+
if (error) {
|
|
318
|
+
*error = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-100 userInfo:@{NSLocalizedDescriptionKey: @"Cannot add video input to writer"}];
|
|
319
|
+
}
|
|
320
|
+
return NO;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
[g_videoWriter addInput:g_videoInput];
|
|
324
|
+
if (g_pixelBufferAdaptorRef) {
|
|
325
|
+
CFRelease(g_pixelBufferAdaptorRef);
|
|
326
|
+
g_pixelBufferAdaptorRef = NULL;
|
|
327
|
+
}
|
|
328
|
+
if (pixelAdaptor) {
|
|
329
|
+
g_pixelBufferAdaptorRef = CFBridgingRetain(pixelAdaptor);
|
|
330
|
+
}
|
|
331
|
+
g_videoWriterStarted = NO;
|
|
332
|
+
g_videoStartTime = kCMTimeInvalid;
|
|
333
|
+
MRLog(@"✅ Video writer ready %ldx%ld", (long)width, (long)height);
|
|
334
|
+
|
|
335
|
+
return YES;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
+ (BOOL)prepareAudioWriterIfNeededWithSampleBuffer:(CMSampleBufferRef)sampleBuffer {
|
|
339
|
+
if (!g_shouldCaptureAudio || g_audioWriter || !g_audioOutputPath) {
|
|
340
|
+
return g_audioWriter != nil || !g_shouldCaptureAudio;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
CMFormatDescriptionRef formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer);
|
|
344
|
+
if (!formatDescription) {
|
|
345
|
+
NSLog(@"⚠️ Missing audio format description");
|
|
346
|
+
return NO;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const AudioStreamBasicDescription *asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription);
|
|
350
|
+
if (!asbd) {
|
|
351
|
+
NSLog(@"⚠️ Unsupported audio format description");
|
|
352
|
+
return NO;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
g_configuredSampleRate = (NSInteger)asbd->mSampleRate;
|
|
356
|
+
g_configuredChannelCount = asbd->mChannelsPerFrame;
|
|
357
|
+
|
|
358
|
+
NSString *originalPath = g_audioOutputPath ?: @"";
|
|
359
|
+
NSURL *audioURL = [NSURL fileURLWithPath:originalPath];
|
|
360
|
+
[[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
|
|
361
|
+
|
|
362
|
+
NSError *writerError = nil;
|
|
363
|
+
AVFileType requestedFileType = AVFileTypeQuickTimeMovie;
|
|
364
|
+
BOOL requestedWebM = NO;
|
|
365
|
+
if (@available(macOS 15.0, *)) {
|
|
366
|
+
requestedFileType = @"public.webm";
|
|
367
|
+
requestedWebM = YES;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@try {
|
|
371
|
+
g_audioWriter = [[AVAssetWriter alloc] initWithURL:audioURL fileType:requestedFileType error:&writerError];
|
|
372
|
+
} @catch (NSException *exception) {
|
|
373
|
+
NSDictionary *info = @{
|
|
374
|
+
NSLocalizedDescriptionKey: exception.reason ?: @"Failed to initialize audio writer"
|
|
375
|
+
};
|
|
376
|
+
writerError = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-201 userInfo:info];
|
|
377
|
+
g_audioWriter = nil;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if ((!g_audioWriter || writerError) && requestedWebM) {
|
|
381
|
+
MRLog(@"⚠️ ScreenCaptureKit audio writer unavailable (%@) – falling back to QuickTime container", writerError.localizedDescription);
|
|
382
|
+
NSString *fallbackPath = [[originalPath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mov"];
|
|
383
|
+
if (!fallbackPath || [fallbackPath length] == 0) {
|
|
384
|
+
fallbackPath = [originalPath stringByAppendingString:@".mov"];
|
|
385
|
+
}
|
|
386
|
+
[[NSFileManager defaultManager] removeItemAtPath:fallbackPath error:nil];
|
|
387
|
+
NSURL *fallbackURL = [NSURL fileURLWithPath:fallbackPath];
|
|
388
|
+
g_audioOutputPath = fallbackPath;
|
|
389
|
+
writerError = nil;
|
|
390
|
+
@try {
|
|
391
|
+
g_audioWriter = [[AVAssetWriter alloc] initWithURL:fallbackURL fileType:AVFileTypeQuickTimeMovie error:&writerError];
|
|
392
|
+
} @catch (NSException *exception) {
|
|
393
|
+
NSDictionary *info = @{
|
|
394
|
+
NSLocalizedDescriptionKey: exception.reason ?: @"Failed to initialize audio writer"
|
|
395
|
+
};
|
|
396
|
+
writerError = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-202 userInfo:info];
|
|
397
|
+
g_audioWriter = nil;
|
|
398
|
+
}
|
|
399
|
+
audioURL = fallbackURL;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!g_audioWriter || writerError) {
|
|
403
|
+
NSLog(@"❌ Failed to create audio writer: %@", writerError);
|
|
404
|
+
return NO;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
NSInteger channelCount = MAX(1, g_configuredChannelCount);
|
|
408
|
+
AudioChannelLayout layout = {0};
|
|
409
|
+
size_t layoutSize = 0;
|
|
410
|
+
if (channelCount == 1) {
|
|
411
|
+
layout.mChannelLayoutTag = kAudioChannelLayoutTag_Mono;
|
|
412
|
+
layoutSize = sizeof(AudioChannelLayout);
|
|
413
|
+
} else if (channelCount == 2) {
|
|
414
|
+
layout.mChannelLayoutTag = kAudioChannelLayoutTag_Stereo;
|
|
415
|
+
layoutSize = sizeof(AudioChannelLayout);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
NSMutableDictionary *audioSettings = [@{
|
|
419
|
+
AVFormatIDKey: @(kAudioFormatMPEG4AAC),
|
|
420
|
+
AVSampleRateKey: @(g_configuredSampleRate),
|
|
421
|
+
AVNumberOfChannelsKey: @(channelCount),
|
|
422
|
+
AVEncoderBitRateKey: @(192000)
|
|
423
|
+
} mutableCopy];
|
|
424
|
+
|
|
425
|
+
if (layoutSize > 0) {
|
|
426
|
+
audioSettings[AVChannelLayoutKey] = [NSData dataWithBytes:&layout length:layoutSize];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
g_audioInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio outputSettings:audioSettings];
|
|
430
|
+
g_audioInput.expectsMediaDataInRealTime = YES;
|
|
431
|
+
|
|
432
|
+
if (![g_audioWriter canAddInput:g_audioInput]) {
|
|
433
|
+
NSLog(@"❌ Audio writer cannot add input");
|
|
434
|
+
return NO;
|
|
435
|
+
}
|
|
436
|
+
[g_audioWriter addInput:g_audioInput];
|
|
437
|
+
g_audioWriterStarted = NO;
|
|
438
|
+
g_audioStartTime = kCMTimeInvalid;
|
|
439
|
+
|
|
440
|
+
return YES;
|
|
441
|
+
}
|
|
442
|
+
|
|
44
443
|
+ (BOOL)isScreenCaptureKitAvailable {
|
|
45
444
|
if (@available(macOS 15.0, *)) {
|
|
46
445
|
return [SCShareableContent class] != nil && [SCStream class] != nil && [SCRecordingOutput class] != nil;
|
|
@@ -73,6 +472,9 @@ static NSString *g_outputPath = nil;
|
|
|
73
472
|
NSNumber *captureCursor = config[@"captureCursor"];
|
|
74
473
|
NSNumber *includeMicrophone = config[@"includeMicrophone"];
|
|
75
474
|
NSNumber *includeSystemAudio = config[@"includeSystemAudio"];
|
|
475
|
+
NSString *microphoneDeviceId = config[@"microphoneDeviceId"];
|
|
476
|
+
NSString *audioOutputPath = MRNormalizePath(config[@"audioOutputPath"]);
|
|
477
|
+
NSNumber *sessionTimestampNumber = config[@"sessionTimestamp"];
|
|
76
478
|
|
|
77
479
|
MRLog(@"🎬 Starting PURE ScreenCaptureKit recording (NO AVFoundation)");
|
|
78
480
|
MRLog(@"🔧 Config: cursor=%@ mic=%@ system=%@ display=%@ window=%@ crop=%@",
|
|
@@ -188,6 +590,29 @@ static NSString *g_outputPath = nil;
|
|
|
188
590
|
streamConfig.pixelFormat = kCVPixelFormatType_32BGRA;
|
|
189
591
|
streamConfig.scalesToFit = NO;
|
|
190
592
|
|
|
593
|
+
BOOL shouldCaptureMic = includeMicrophone ? [includeMicrophone boolValue] : NO;
|
|
594
|
+
BOOL shouldCaptureSystemAudio = includeSystemAudio ? [includeSystemAudio boolValue] : NO;
|
|
595
|
+
g_shouldCaptureAudio = shouldCaptureMic || shouldCaptureSystemAudio;
|
|
596
|
+
g_audioOutputPath = audioOutputPath;
|
|
597
|
+
if (g_shouldCaptureAudio && (!g_audioOutputPath || [g_audioOutputPath length] == 0)) {
|
|
598
|
+
NSLog(@"⚠️ Audio capture requested but no audio output path supplied – audio will be disabled");
|
|
599
|
+
g_shouldCaptureAudio = NO;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (@available(macos 13.0, *)) {
|
|
603
|
+
streamConfig.capturesAudio = g_shouldCaptureAudio;
|
|
604
|
+
streamConfig.sampleRate = g_configuredSampleRate;
|
|
605
|
+
streamConfig.channelCount = g_configuredChannelCount;
|
|
606
|
+
streamConfig.excludesCurrentProcessAudio = !shouldCaptureSystemAudio;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (@available(macos 15.0, *)) {
|
|
610
|
+
streamConfig.captureMicrophone = shouldCaptureMic;
|
|
611
|
+
if (microphoneDeviceId && microphoneDeviceId.length > 0) {
|
|
612
|
+
streamConfig.microphoneCaptureDeviceID = microphoneDeviceId;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
191
616
|
// Apply crop area using sourceRect - CONVERT GLOBAL TO DISPLAY-RELATIVE COORDINATES
|
|
192
617
|
if (captureRect && captureRect[@"x"] && captureRect[@"y"] && captureRect[@"width"] && captureRect[@"height"]) {
|
|
193
618
|
CGFloat globalX = [captureRect[@"x"] doubleValue];
|
|
@@ -232,110 +657,63 @@ static NSString *g_outputPath = nil;
|
|
|
232
657
|
MRLog(@"🎥 Pure ScreenCapture config: %ldx%ld @ 30fps, cursor=%d",
|
|
233
658
|
recordingWidth, recordingHeight, shouldShowCursor);
|
|
234
659
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
BOOL shouldCaptureSystemAudio = includeSystemAudio ? [includeSystemAudio boolValue] : NO;
|
|
239
|
-
MRLog(@"🔍 AUDIO COMPUTED: shouldCaptureMic=%d shouldCaptureSystemAudio=%d", shouldCaptureMic, shouldCaptureSystemAudio);
|
|
240
|
-
|
|
241
|
-
// Enable audio if either microphone or system audio is requested
|
|
242
|
-
if (@available(macOS 13.0, *)) {
|
|
243
|
-
if (shouldCaptureMic || shouldCaptureSystemAudio) {
|
|
244
|
-
streamConfig.capturesAudio = YES;
|
|
245
|
-
streamConfig.sampleRate = 44100;
|
|
246
|
-
streamConfig.channelCount = 2;
|
|
247
|
-
|
|
248
|
-
if (shouldCaptureMic && shouldCaptureSystemAudio) {
|
|
249
|
-
MRLog(@"🎵 Both microphone and system audio enabled");
|
|
250
|
-
} else if (shouldCaptureMic) {
|
|
251
|
-
MRLog(@"🎤 Microphone audio enabled");
|
|
252
|
-
} else {
|
|
253
|
-
MRLog(@"🔊 System audio enabled");
|
|
254
|
-
}
|
|
255
|
-
} else {
|
|
256
|
-
streamConfig.capturesAudio = NO;
|
|
257
|
-
MRLog(@"🔇 Audio disabled");
|
|
258
|
-
}
|
|
259
|
-
} else {
|
|
260
|
-
streamConfig.capturesAudio = NO;
|
|
261
|
-
MRLog(@"🔇 Audio disabled (macOS < 13.0)");
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Create pure ScreenCaptureKit recording output
|
|
265
|
-
// Use local copy to prevent race conditions
|
|
266
|
-
NSString *safeOutputPath = outputPath; // Local variable from outer scope
|
|
267
|
-
if (!safeOutputPath || [safeOutputPath length] == 0) {
|
|
268
|
-
NSLog(@"❌ Output path is nil or empty");
|
|
660
|
+
NSError *writerError = nil;
|
|
661
|
+
if (![ScreenCaptureKitRecorder prepareVideoWriterWithWidth:recordingWidth height:recordingHeight error:&writerError]) {
|
|
662
|
+
NSLog(@"❌ Failed to prepare video writer: %@", writerError);
|
|
269
663
|
return;
|
|
270
664
|
}
|
|
271
665
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// Create recording output configuration
|
|
280
|
-
SCRecordingOutputConfiguration *recordingConfig = [[SCRecordingOutputConfiguration alloc] init];
|
|
281
|
-
recordingConfig.outputURL = outputURL;
|
|
282
|
-
recordingConfig.videoCodecType = AVVideoCodecTypeH264;
|
|
283
|
-
|
|
284
|
-
// Audio configuration - using available properties
|
|
285
|
-
// Note: Specific audio routing handled by ScreenCaptureKit automatically
|
|
286
|
-
|
|
287
|
-
// Create recording output with correct initializer
|
|
288
|
-
g_recordingOutput = [[SCRecordingOutput alloc] initWithConfiguration:recordingConfig
|
|
289
|
-
delegate:nil];
|
|
290
|
-
if (shouldCaptureMic && shouldCaptureSystemAudio) {
|
|
291
|
-
NSLog(@"🔧 Created SCRecordingOutput with microphone and system audio");
|
|
292
|
-
} else if (shouldCaptureMic) {
|
|
293
|
-
NSLog(@"🔧 Created SCRecordingOutput with microphone audio");
|
|
294
|
-
} else if (shouldCaptureSystemAudio) {
|
|
295
|
-
NSLog(@"🔧 Created SCRecordingOutput with system audio");
|
|
296
|
-
} else {
|
|
297
|
-
NSLog(@"🔧 Created SCRecordingOutput (audio disabled)");
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (!g_recordingOutput) {
|
|
302
|
-
NSLog(@"❌ Failed to create SCRecordingOutput");
|
|
303
|
-
return;
|
|
666
|
+
g_videoQueue = dispatch_queue_create("screen_capture_video_queue", DISPATCH_QUEUE_SERIAL);
|
|
667
|
+
g_audioQueue = dispatch_queue_create("screen_capture_audio_queue", DISPATCH_QUEUE_SERIAL);
|
|
668
|
+
g_videoStreamOutput = [[ScreenCaptureVideoOutput alloc] init];
|
|
669
|
+
if (g_shouldCaptureAudio) {
|
|
670
|
+
g_audioStreamOutput = [[ScreenCaptureAudioOutput alloc] init];
|
|
671
|
+
} else {
|
|
672
|
+
g_audioStreamOutput = nil;
|
|
304
673
|
}
|
|
305
674
|
|
|
306
|
-
NSLog(@"✅ Pure ScreenCaptureKit recording output created");
|
|
307
|
-
|
|
308
|
-
// Create delegate
|
|
309
675
|
g_streamDelegate = [[PureScreenCaptureDelegate alloc] init];
|
|
310
|
-
|
|
311
|
-
// Create and configure stream
|
|
312
676
|
g_stream = [[SCStream alloc] initWithFilter:filter configuration:streamConfig delegate:g_streamDelegate];
|
|
313
677
|
|
|
314
678
|
if (!g_stream) {
|
|
315
679
|
NSLog(@"❌ Failed to create pure stream");
|
|
680
|
+
CleanupWriters();
|
|
316
681
|
return;
|
|
317
682
|
}
|
|
318
683
|
|
|
319
|
-
// Add recording output directly to stream
|
|
320
684
|
NSError *outputError = nil;
|
|
321
|
-
BOOL
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
685
|
+
BOOL videoOutputAdded = [g_stream addStreamOutput:g_videoStreamOutput type:SCStreamOutputTypeScreen sampleHandlerQueue:g_videoQueue error:&outputError];
|
|
686
|
+
if (!videoOutputAdded || outputError) {
|
|
687
|
+
NSLog(@"❌ Failed to add video output: %@", outputError);
|
|
688
|
+
CleanupWriters();
|
|
689
|
+
return;
|
|
325
690
|
}
|
|
326
691
|
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
692
|
+
if (g_shouldCaptureAudio) {
|
|
693
|
+
if (@available(macOS 13.0, *)) {
|
|
694
|
+
NSError *audioError = nil;
|
|
695
|
+
BOOL audioOutputAdded = [g_stream addStreamOutput:g_audioStreamOutput type:SCStreamOutputTypeAudio sampleHandlerQueue:g_audioQueue error:&audioError];
|
|
696
|
+
if (!audioOutputAdded || audioError) {
|
|
697
|
+
NSLog(@"❌ Failed to add audio output: %@", audioError);
|
|
698
|
+
CleanupWriters();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
} else {
|
|
702
|
+
NSLog(@"⚠️ Audio capture requested but requires macOS 13.0+");
|
|
703
|
+
g_shouldCaptureAudio = NO;
|
|
704
|
+
}
|
|
330
705
|
}
|
|
331
706
|
|
|
332
|
-
MRLog(@"✅
|
|
707
|
+
MRLog(@"✅ Stream outputs configured (audio=%d)", g_shouldCaptureAudio);
|
|
708
|
+
if (sessionTimestampNumber) {
|
|
709
|
+
MRLog(@"🕒 Session timestamp: %@", sessionTimestampNumber);
|
|
710
|
+
}
|
|
333
711
|
|
|
334
|
-
// Start capture with recording
|
|
335
712
|
[g_stream startCaptureWithCompletionHandler:^(NSError *startError) {
|
|
336
713
|
if (startError) {
|
|
337
714
|
NSLog(@"❌ Failed to start pure capture: %@", startError);
|
|
338
715
|
g_isRecording = NO;
|
|
716
|
+
CleanupWriters();
|
|
339
717
|
} else {
|
|
340
718
|
MRLog(@"🎉 PURE ScreenCaptureKit recording started successfully!");
|
|
341
719
|
g_isRecording = YES;
|
|
@@ -368,6 +746,7 @@ static NSString *g_outputPath = nil;
|
|
|
368
746
|
|
|
369
747
|
// Finalize on main queue to prevent threading issues
|
|
370
748
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
749
|
+
CleanupWriters();
|
|
371
750
|
[ScreenCaptureKitRecorder cleanupVideoWriter];
|
|
372
751
|
});
|
|
373
752
|
}];
|
|
@@ -390,11 +769,6 @@ static NSString *g_outputPath = nil;
|
|
|
390
769
|
g_isCleaningUp = YES;
|
|
391
770
|
g_isRecording = NO;
|
|
392
771
|
|
|
393
|
-
if (g_recordingOutput) {
|
|
394
|
-
// SCRecordingOutput finalizes automatically
|
|
395
|
-
MRLog(@"✅ Pure recording output finalized");
|
|
396
|
-
}
|
|
397
|
-
|
|
398
772
|
[ScreenCaptureKitRecorder cleanupVideoWriter];
|
|
399
773
|
}
|
|
400
774
|
}
|
|
@@ -414,16 +788,22 @@ static NSString *g_outputPath = nil;
|
|
|
414
788
|
MRLog(@"✅ Stream reference cleared");
|
|
415
789
|
}
|
|
416
790
|
|
|
417
|
-
if (g_recordingOutput) {
|
|
418
|
-
g_recordingOutput = nil;
|
|
419
|
-
MRLog(@"✅ Recording output reference cleared");
|
|
420
|
-
}
|
|
421
|
-
|
|
422
791
|
if (g_streamDelegate) {
|
|
423
792
|
g_streamDelegate = nil;
|
|
424
793
|
MRLog(@"✅ Stream delegate reference cleared");
|
|
425
794
|
}
|
|
426
795
|
|
|
796
|
+
g_videoStreamOutput = nil;
|
|
797
|
+
g_audioStreamOutput = nil;
|
|
798
|
+
g_videoQueue = nil;
|
|
799
|
+
g_audioQueue = nil;
|
|
800
|
+
if (g_pixelBufferAdaptorRef) {
|
|
801
|
+
CFRelease(g_pixelBufferAdaptorRef);
|
|
802
|
+
g_pixelBufferAdaptorRef = NULL;
|
|
803
|
+
}
|
|
804
|
+
g_audioOutputPath = nil;
|
|
805
|
+
g_shouldCaptureAudio = NO;
|
|
806
|
+
|
|
427
807
|
g_isRecording = NO;
|
|
428
808
|
g_isCleaningUp = NO; // Reset cleanup flag
|
|
429
809
|
g_outputPath = nil;
|
package/src/audio_capture.mm
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
#import <Foundation/Foundation.h>
|
|
2
|
-
|
|
3
|
-
@interface AudioCapture : NSObject
|
|
4
|
-
|
|
5
|
-
+ (NSArray *)getAudioDevices;
|
|
6
|
-
+ (BOOL)hasAudioPermission;
|
|
7
|
-
+ (void)requestAudioPermission:(void(^)(BOOL granted))completion;
|
|
8
|
-
|
|
9
|
-
@end
|
|
10
|
-
|
|
11
|
-
@implementation AudioCapture
|
|
12
|
-
|
|
13
|
-
+ (NSArray *)getAudioDevices {
|
|
14
|
-
NSMutableArray *devices = [NSMutableArray array];
|
|
15
|
-
|
|
16
|
-
// ScreenCaptureKit handles audio internally - return default device
|
|
17
|
-
NSDictionary *deviceInfo = @{
|
|
18
|
-
@"id": @"default",
|
|
19
|
-
@"name": @"Default Audio Device",
|
|
20
|
-
@"manufacturer": @"System",
|
|
21
|
-
@"isDefault": @YES
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
[devices addObject:deviceInfo];
|
|
25
|
-
|
|
26
|
-
return devices;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
+ (BOOL)hasAudioPermission {
|
|
30
|
-
// ScreenCaptureKit handles audio permissions internally
|
|
31
|
-
return YES;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
+ (void)requestAudioPermission:(void(^)(BOOL granted))completion {
|
|
35
|
-
// ScreenCaptureKit handles audio permissions internally
|
|
36
|
-
if (completion) {
|
|
37
|
-
completion(YES);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
@end
|