node-mac-recorder 2.21.20 → 2.21.22

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.
@@ -5,7 +5,11 @@
5
5
  "Bash(chmod:*)",
6
6
  "Bash(node test-sync.js:*)",
7
7
  "Bash(node:*)",
8
- "Bash(ALLOW_CONTINUITY_CAMERA=1 node:*)"
8
+ "Bash(ALLOW_CONTINUITY_CAMERA=1 node:*)",
9
+ "Bash(awk:*)",
10
+ "Bash(ffprobe:*)",
11
+ "Bash(sw_vers:*)",
12
+ "Bash(system_profiler:*)"
9
13
  ],
10
14
  "deny": [],
11
15
  "ask": []
package/index.js CHANGED
@@ -103,6 +103,7 @@ class MacRecorder extends EventEmitter {
103
103
  position: device?.position ?? "unspecified",
104
104
  transportType: device?.transportType ?? null,
105
105
  isConnected: device?.isConnected ?? false,
106
+ isDefault: device?.isDefault === true,
106
107
  hasFlash: device?.hasFlash ?? false,
107
108
  supportsDepth: device?.supportsDepth ?? false,
108
109
  deviceType: device?.deviceType ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.21.20",
3
+ "version": "2.21.22",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -224,10 +224,30 @@ static dispatch_queue_t g_audioCaptureQueue = nil;
224
224
  return YES;
225
225
  }
226
226
 
227
- [self.session stopRunning];
227
+ // CRITICAL FIX: For external devices (especially Continuity Microphone),
228
+ // stopRunning can hang if device is disconnected. Use async approach.
229
+ MRLog(@"🛑 AudioRecorder: Stopping session (external device safe)...");
230
+
231
+ // Stop session on background thread to avoid blocking
232
+ AVCaptureSession *sessionToStop = self.session;
233
+ AVCaptureAudioDataOutput *outputToStop = self.audioOutput;
234
+
235
+ // Clear references FIRST to prevent new samples
228
236
  self.session = nil;
229
237
  self.audioOutput = nil;
230
238
 
239
+ // Stop session asynchronously with timeout protection
240
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
241
+ @autoreleasepool {
242
+ if ([sessionToStop isRunning]) {
243
+ MRLog(@"🛑 Stopping AVCaptureSession...");
244
+ [sessionToStop stopRunning];
245
+ MRLog(@"✅ AVCaptureSession stopped");
246
+ }
247
+ // Release happens automatically when block completes
248
+ }
249
+ });
250
+
231
251
  // CRITICAL FIX: Check if writer exists before trying to finish it
232
252
  if (self.writer) {
233
253
  // Only mark as finished if writerInput exists
@@ -243,16 +263,16 @@ static dispatch_queue_t g_audioCaptureQueue = nil;
243
263
  dispatch_semaphore_signal(semaphore);
244
264
  }];
245
265
 
246
- // Reduced timeout to 2 seconds for faster response
247
- dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC));
248
- dispatch_semaphore_wait(semaphore, timeout);
266
+ // Reduced timeout to 1 second for external devices
267
+ dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC));
268
+ long result = dispatch_semaphore_wait(semaphore, timeout);
249
269
 
250
- if (!finished) {
251
- MRLog(@"⚠️ AudioRecorder: Timed out waiting for writer to finish");
270
+ if (result != 0 || !finished) {
271
+ MRLog(@"⚠️ AudioRecorder: Timed out waiting for writer (external device?) - forcing cancel");
252
272
  // Force cancel if timeout
253
273
  [self.writer cancelWriting];
254
274
  } else {
255
- MRLog(@"✅ AudioRecorder stopped successfully");
275
+ MRLog(@"✅ AudioRecorder writer finished successfully");
256
276
  }
257
277
  } else {
258
278
  MRLog(@"⚠️ AudioRecorder: No writer to finish (no audio captured)");
@@ -264,6 +284,7 @@ static dispatch_queue_t g_audioCaptureQueue = nil;
264
284
  self.startTime = kCMTimeInvalid;
265
285
  self.outputPath = nil;
266
286
 
287
+ MRLog(@"✅ AudioRecorder stopped (safe for external devices)");
267
288
  return YES;
268
289
  }
269
290
 
@@ -334,11 +355,37 @@ NSArray<NSDictionary *> *listAudioCaptureDevices() {
334
355
  position:AVCaptureDevicePositionUnspecified];
335
356
 
336
357
  for (AVCaptureDevice *device in session.devices) {
358
+ // PRIORITY FIX: MacBook built-in devices should be default, not external devices
359
+ // Check if this is a built-in device (MacBook's own microphone)
360
+ NSString *deviceName = device.localizedName ?: @"";
361
+ BOOL isBuiltIn = NO;
362
+
363
+ // Built-in detection: Check for "MacBook", "iMac", "Mac Studio", "Mac mini", "Mac Pro" in name
364
+ if ([deviceName rangeOfString:@"MacBook" options:NSCaseInsensitiveSearch].location != NSNotFound ||
365
+ [deviceName rangeOfString:@"iMac" options:NSCaseInsensitiveSearch].location != NSNotFound ||
366
+ [deviceName rangeOfString:@"Mac Studio" options:NSCaseInsensitiveSearch].location != NSNotFound ||
367
+ [deviceName rangeOfString:@"Mac mini" options:NSCaseInsensitiveSearch].location != NSNotFound ||
368
+ [deviceName rangeOfString:@"Mac Pro" options:NSCaseInsensitiveSearch].location != NSNotFound) {
369
+ isBuiltIn = YES;
370
+ }
371
+
372
+ // Also check for generic "Built-in" in name
373
+ if ([deviceName rangeOfString:@"Built-in" options:NSCaseInsensitiveSearch].location != NSNotFound) {
374
+ isBuiltIn = YES;
375
+ }
376
+
377
+ // External devices (Continuity, USB, etc.) should NOT be default
378
+ if ([deviceName rangeOfString:@"Continuity" options:NSCaseInsensitiveSearch].location != NSNotFound ||
379
+ [deviceName rangeOfString:@"iPhone" options:NSCaseInsensitiveSearch].location != NSNotFound ||
380
+ [deviceName rangeOfString:@"iPad" options:NSCaseInsensitiveSearch].location != NSNotFound) {
381
+ isBuiltIn = NO;
382
+ }
383
+
337
384
  NSDictionary *info = @{
338
385
  @"id": device.uniqueID ?: @"",
339
- @"name": device.localizedName ?: @"Unknown Audio Device",
386
+ @"name": deviceName,
340
387
  @"manufacturer": device.manufacturer ?: @"",
341
- @"isDefault": @(device.connected),
388
+ @"isDefault": @(isBuiltIn), // Only built-in devices are default
342
389
  @"transportType": @(device.transportType)
343
390
  };
344
391
  [devicesInfo addObject:info];
@@ -120,16 +120,30 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
120
120
  codecKey = AVVideoCodecH264;
121
121
  }
122
122
 
123
+ // QUALITY FIX: ULTRA HIGH quality screen recording
124
+ // ProMotion displays may capture at 10 FPS - use very high bitrate for perfect quality
125
+ NSInteger bitrate = (NSInteger)(recordingSize.width * recordingSize.height * 30);
126
+ bitrate = MAX(bitrate, 30 * 1000 * 1000); // Minimum 30 Mbps
127
+ bitrate = MIN(bitrate, 120 * 1000 * 1000); // Maximum 120 Mbps
128
+
129
+ NSLog(@"🎬 ULTRA QUALITY AVFoundation: %dx%d, bitrate=%.2fMbps",
130
+ (int)recordingSize.width, (int)recordingSize.height, bitrate / (1000.0 * 1000.0));
131
+
123
132
  NSDictionary *videoSettings = @{
124
133
  AVVideoCodecKey: codecKey,
125
134
  AVVideoWidthKey: @((int)recordingSize.width),
126
135
  AVVideoHeightKey: @((int)recordingSize.height),
127
136
  AVVideoCompressionPropertiesKey: @{
128
- AVVideoAverageBitRateKey: @(recordingSize.width * recordingSize.height * 8),
129
- AVVideoMaxKeyFrameIntervalKey: @30
137
+ AVVideoAverageBitRateKey: @(bitrate),
138
+ AVVideoMaxKeyFrameIntervalKey: @30,
139
+ AVVideoAllowFrameReorderingKey: @YES,
140
+ AVVideoExpectedSourceFrameRateKey: @60,
141
+ AVVideoQualityKey: @(0.95), // 0.0-1.0, higher is better
142
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
143
+ AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCABAC
130
144
  }
131
145
  };
132
-
146
+
133
147
  NSLog(@"🔧 Using codec: %@", codecKey);
134
148
 
135
149
  // Create video input
@@ -189,18 +189,58 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
189
189
  position = @"unspecified";
190
190
  break;
191
191
  }
192
-
192
+
193
+ // PRIORITY FIX: MacBook built-in cameras should be default, not external cameras
194
+ // Check if this is a built-in camera (MacBook's own camera)
195
+ NSString *deviceName = device.localizedName ?: @"";
196
+ NSString *deviceType = device.deviceType ?: @"";
197
+ BOOL isBuiltIn = NO;
198
+
199
+ // Built-in detection: Check for common built-in camera names
200
+ if ([deviceName rangeOfString:@"FaceTime" options:NSCaseInsensitiveSearch].location != NSNotFound ||
201
+ [deviceName rangeOfString:@"iSight" options:NSCaseInsensitiveSearch].location != NSNotFound ||
202
+ [deviceName rangeOfString:@"Built-in" options:NSCaseInsensitiveSearch].location != NSNotFound) {
203
+ isBuiltIn = YES;
204
+ }
205
+
206
+ // Check device type for built-in wide angle camera
207
+ if (@available(macOS 10.15, *)) {
208
+ if ([deviceType isEqualToString:AVCaptureDeviceTypeBuiltInWideAngleCamera]) {
209
+ isBuiltIn = YES;
210
+ }
211
+ }
212
+
213
+ // External devices (Continuity Camera, iPhone, iPad, USB) should NOT be default
214
+ if (continuityCamera ||
215
+ [deviceName rangeOfString:@"iPhone" options:NSCaseInsensitiveSearch].location != NSNotFound ||
216
+ [deviceName rangeOfString:@"iPad" options:NSCaseInsensitiveSearch].location != NSNotFound ||
217
+ [deviceName rangeOfString:@"Continuity" options:NSCaseInsensitiveSearch].location != NSNotFound) {
218
+ isBuiltIn = NO;
219
+ }
220
+
221
+ // External device types should not be default
222
+ if (@available(macOS 14.0, *)) {
223
+ if ([deviceType isEqualToString:AVCaptureDeviceTypeExternal] ||
224
+ [deviceType isEqualToString:AVCaptureDeviceTypeContinuityCamera]) {
225
+ isBuiltIn = NO;
226
+ }
227
+ }
228
+ if ([deviceType isEqualToString:AVCaptureDeviceTypeExternalUnknown]) {
229
+ isBuiltIn = NO;
230
+ }
231
+
193
232
  NSDictionary *deviceInfo = @{
194
233
  @"id": device.uniqueID ?: @"",
195
- @"name": device.localizedName ?: @"Unknown Camera",
234
+ @"name": deviceName,
196
235
  @"model": device.modelID ?: @"",
197
236
  @"manufacturer": device.manufacturer ?: @"",
198
237
  @"position": position ?: @"unspecified",
199
238
  @"transportType": @(device.transportType),
200
239
  @"isConnected": @(device.isConnected),
240
+ @"isDefault": @(isBuiltIn), // Only built-in cameras are default
201
241
  @"hasFlash": @(device.hasFlash),
202
242
  @"supportsDepth": @NO,
203
- @"deviceType": device.deviceType ?: @"",
243
+ @"deviceType": deviceType,
204
244
  @"requiresContinuityCameraPermission": @(continuityCamera),
205
245
  @"maxResolution": @{
206
246
  @"width": @(bestDimensions.width),
@@ -678,17 +718,33 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
678
718
  return YES;
679
719
  }
680
720
 
721
+ // CRITICAL FIX: For external cameras (especially Continuity Camera/iPhone),
722
+ // stopRunning can hang if device is disconnected. Use async approach.
723
+ MRLog(@"🛑 CameraRecorder: Stopping session (external device safe)...");
724
+
681
725
  self.isShuttingDown = YES;
682
726
  self.isRecording = NO;
683
727
 
684
- @try {
685
- [self.session stopRunning];
686
- } @catch (NSException *exception) {
687
- MRLog(@"⚠️ CameraRecorder: Exception while stopping session: %@", exception.reason);
688
- }
689
-
728
+ // Stop delegate FIRST to prevent new frames
690
729
  [self.videoOutput setSampleBufferDelegate:nil queue:nil];
691
730
 
731
+ // Stop session on background thread to avoid blocking
732
+ AVCaptureSession *sessionToStop = self.session;
733
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
734
+ @autoreleasepool {
735
+ @try {
736
+ if ([sessionToStop isRunning]) {
737
+ MRLog(@"🛑 Stopping AVCaptureSession (camera)...");
738
+ [sessionToStop stopRunning];
739
+ MRLog(@"✅ AVCaptureSession stopped (camera)");
740
+ }
741
+ } @catch (NSException *exception) {
742
+ MRLog(@"⚠️ CameraRecorder: Exception while stopping session: %@", exception.reason);
743
+ }
744
+ // Release happens automatically when block completes
745
+ }
746
+ });
747
+
692
748
  // CRITICAL FIX: Check if assetWriter exists before trying to finish it
693
749
  // If no frames were captured, assetWriter will be nil
694
750
  if (!self.assetWriter) {
@@ -709,12 +765,12 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
709
765
  dispatch_semaphore_signal(semaphore);
710
766
  }];
711
767
 
712
- // Reduced timeout to 2 seconds for faster response
713
- dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC));
714
- dispatch_semaphore_wait(semaphore, timeout);
768
+ // Reduced timeout to 1 second for external devices
769
+ dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC));
770
+ long result = dispatch_semaphore_wait(semaphore, timeout);
715
771
 
716
- if (!finished) {
717
- MRLog(@"⚠️ CameraRecorder: Timed out waiting for writer to finish");
772
+ if (result != 0 || !finished) {
773
+ MRLog(@"⚠️ CameraRecorder: Timed out waiting for writer (external device?) - forcing cancel");
718
774
  // Force cancel if timeout
719
775
  [self.assetWriter cancelWriting];
720
776
  }
@@ -723,10 +779,11 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
723
779
  if (!success) {
724
780
  MRLog(@"⚠️ CameraRecorder: Writer finished with status %ld error %@", (long)self.assetWriter.status, self.assetWriter.error);
725
781
  } else {
726
- MRLog(@"✅ CameraRecorder stopped successfully");
782
+ MRLog(@"✅ CameraRecorder writer finished successfully");
727
783
  }
728
784
 
729
785
  [self resetState];
786
+ MRLog(@"✅ CameraRecorder stopped (safe for external devices)");
730
787
  return success;
731
788
  }
732
789
 
@@ -827,6 +827,7 @@ Napi::Value GetCameraDevices(const Napi::CallbackInfo& info) {
827
827
  NSString *position = camera[@"position"];
828
828
  NSNumber *transportType = camera[@"transportType"];
829
829
  NSNumber *isConnected = camera[@"isConnected"];
830
+ NSNumber *isDefault = camera[@"isDefault"];
830
831
  NSNumber *hasFlash = camera[@"hasFlash"];
831
832
  NSNumber *supportsDepth = camera[@"supportsDepth"];
832
833
 
@@ -861,7 +862,11 @@ Napi::Value GetCameraDevices(const Napi::CallbackInfo& info) {
861
862
  if (isConnected && [isConnected isKindOfClass:[NSNumber class]]) {
862
863
  cameraObj.Set("isConnected", Napi::Boolean::New(env, [isConnected boolValue]));
863
864
  }
864
-
865
+
866
+ if (isDefault && [isDefault isKindOfClass:[NSNumber class]]) {
867
+ cameraObj.Set("isDefault", Napi::Boolean::New(env, [isDefault boolValue]));
868
+ }
869
+
865
870
  if (hasFlash && [hasFlash isKindOfClass:[NSNumber class]]) {
866
871
  cameraObj.Set("hasFlash", Napi::Boolean::New(env, [hasFlash boolValue]));
867
872
  }
@@ -33,6 +33,10 @@ static BOOL g_audioWriterStarted = NO;
33
33
  static NSInteger g_configuredSampleRate = 48000;
34
34
  static NSInteger g_configuredChannelCount = 2;
35
35
 
36
+ // Frame rate debugging
37
+ static NSInteger g_frameCount = 0;
38
+ static CFAbsoluteTime g_firstFrameTime = 0;
39
+
36
40
  static void CleanupWriters(void);
37
41
  static AVAssetWriterInputPixelBufferAdaptor * _Nullable CurrentPixelBufferAdaptor(void) {
38
42
  if (!g_pixelBufferAdaptorRef) {
@@ -90,6 +94,10 @@ static void CleanupWriters(void) {
90
94
  }
91
95
  g_videoWriterStarted = NO;
92
96
  g_videoStartTime = kCMTimeInvalid;
97
+
98
+ // Reset frame counting
99
+ g_frameCount = 0;
100
+ g_firstFrameTime = 0;
93
101
  }
94
102
 
95
103
  if (g_audioWriter) {
@@ -217,6 +225,17 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
217
225
  if (!appended) {
218
226
  NSLog(@"⚠️ Failed appending pixel buffer: %@", g_videoWriter.error);
219
227
  }
228
+
229
+ // Frame rate debugging
230
+ g_frameCount++;
231
+ if (g_firstFrameTime == 0) {
232
+ g_firstFrameTime = CFAbsoluteTimeGetCurrent();
233
+ }
234
+ if (g_frameCount % 60 == 0) {
235
+ CFAbsoluteTime elapsed = CFAbsoluteTimeGetCurrent() - g_firstFrameTime;
236
+ double actualFPS = g_frameCount / elapsed;
237
+ MRLog(@"📊 Frame stats: %ld frames in %.1fs = %.1f FPS", (long)g_frameCount, elapsed, actualFPS);
238
+ }
220
239
  }
221
240
  @end
222
241
 
@@ -310,11 +329,27 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
310
329
  return NO;
311
330
  }
312
331
 
332
+ // QUALITY FIX: ULTRA HIGH quality for screen recording
333
+ // ProMotion displays may run at 10Hz (low power) = 10 FPS capture
334
+ // Solution: Use VERY HIGH bitrate so each frame is perfect quality
335
+ // Use 30x multiplier for ULTRA quality (was 6x - way too low!)
336
+ NSInteger bitrate = (NSInteger)(width * height * 30);
337
+ bitrate = MAX(bitrate, 30 * 1000 * 1000); // Minimum 30 Mbps for crystal clear screen recording
338
+ bitrate = MIN(bitrate, 120 * 1000 * 1000); // Maximum 120 Mbps for ultra quality
339
+
340
+ MRLog(@"🎬 ULTRA QUALITY Screen encoder: %ldx%ld, bitrate=%.2fMbps",
341
+ (long)width, (long)height, bitrate / (1000.0 * 1000.0));
342
+
313
343
  NSDictionary *compressionProps = @{
314
- AVVideoAverageBitRateKey: @(width * height * 6),
315
- AVVideoMaxKeyFrameIntervalKey: @30
344
+ AVVideoAverageBitRateKey: @(bitrate),
345
+ AVVideoMaxKeyFrameIntervalKey: @30,
346
+ AVVideoAllowFrameReorderingKey: @YES,
347
+ AVVideoExpectedSourceFrameRateKey: @60,
348
+ AVVideoQualityKey: @(0.95), // 0.0-1.0, higher is better (0.95 = excellent)
349
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
350
+ AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCABAC
316
351
  };
317
-
352
+
318
353
  NSDictionary *videoSettings = @{
319
354
  AVVideoCodecKey: AVVideoCodecTypeH264,
320
355
  AVVideoWidthKey: @(width),
@@ -602,13 +637,20 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
602
637
  }
603
638
  }
604
639
 
605
- // Configure stream with extracted options
640
+ // Configure stream with HIGH QUALITY settings
606
641
  SCStreamConfiguration *streamConfig = [[SCStreamConfiguration alloc] init];
607
642
  streamConfig.width = recordingWidth;
608
643
  streamConfig.height = recordingHeight;
609
- streamConfig.minimumFrameInterval = CMTimeMake(1, 30); // 30 FPS
644
+ streamConfig.minimumFrameInterval = CMTimeMake(1, 60); // 60 FPS for smooth recording
610
645
  streamConfig.pixelFormat = kCVPixelFormatType_32BGRA;
611
646
  streamConfig.scalesToFit = NO;
647
+
648
+ // QUALITY FIX: Set high quality encoding parameters
649
+ if (@available(macOS 13.0, *)) {
650
+ streamConfig.queueDepth = 8; // Larger queue for smoother capture
651
+ }
652
+
653
+ MRLog(@"🎬 ScreenCaptureKit config: %ldx%ld @ 60fps", (long)recordingWidth, (long)recordingHeight);
612
654
 
613
655
  BOOL shouldCaptureMic = includeMicrophone ? [includeMicrophone boolValue] : NO;
614
656
  BOOL shouldCaptureSystemAudio = includeSystemAudio ? [includeSystemAudio boolValue] : NO;