node-mac-recorder 2.21.39 → 2.21.41

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,6 +5,18 @@
5
5
  #import "logging.h"
6
6
  #import "sync_timeline.h"
7
7
 
8
+ #ifdef __cplusplus
9
+ extern "C" {
10
+ #endif
11
+ double MRActiveStopLimitSeconds(void);
12
+ double MRScreenRecordingStartTimestampSeconds(void);
13
+ double currentCameraRecordingStartTime(void);
14
+ #ifdef __cplusplus
15
+ }
16
+ #endif
17
+
18
+ static double g_cameraStartTimestamp = 0.0;
19
+
8
20
  #ifndef AVVideoCodecTypeVP9
9
21
  static AVVideoCodecType const AVVideoCodecTypeVP9 = @"vp09";
10
22
  #endif
@@ -71,26 +83,123 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
71
83
  return NO;
72
84
  }
73
85
 
86
+ static void MRTrimMovieFileIfNeeded(NSString *path, double stopLimitSeconds, double headTrimSeconds) {
87
+ BOOL hasStopLimit = stopLimitSeconds > 0.0;
88
+ BOOL hasHeadTrim = headTrimSeconds > 0.0;
89
+ if (!path || [path length] == 0 || (!hasStopLimit && !hasHeadTrim)) {
90
+ return;
91
+ }
92
+
93
+ NSURL *url = [NSURL fileURLWithPath:path];
94
+ AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
95
+ if (!asset) {
96
+ return;
97
+ }
98
+
99
+ CMTime duration = asset.duration;
100
+ if (!CMTIME_IS_NUMERIC(duration) || duration.value == 0) {
101
+ return;
102
+ }
103
+
104
+ double assetSeconds = CMTimeGetSeconds(duration);
105
+ double tolerance = 0.03;
106
+ if (!hasStopLimit) {
107
+ stopLimitSeconds = assetSeconds;
108
+ }
109
+ if (assetSeconds <= stopLimitSeconds + tolerance && !hasHeadTrim) {
110
+ return;
111
+ }
112
+
113
+ int32_t timescale = duration.timescale > 0 ? duration.timescale : 600;
114
+ double startTrimSeconds = hasHeadTrim ? MIN(headTrimSeconds, stopLimitSeconds - tolerance) : 0.0;
115
+ if (startTrimSeconds < 0.0) {
116
+ startTrimSeconds = 0.0;
117
+ }
118
+
119
+ CMTime targetDuration = CMTimeMakeWithSeconds(stopLimitSeconds, timescale);
120
+ if (CMTIME_COMPARE_INLINE(targetDuration, <=, kCMTimeZero)) {
121
+ return;
122
+ }
123
+
124
+ CMTime startTime = CMTimeMakeWithSeconds(startTrimSeconds, timescale);
125
+ if (CMTIME_COMPARE_INLINE(startTime, >=, targetDuration)) {
126
+ startTime = kCMTimeZero;
127
+ }
128
+ CMTime effectiveDuration = CMTimeSubtract(targetDuration, startTime);
129
+ if (CMTIME_COMPARE_INLINE(effectiveDuration, <=, kCMTimeZero)) {
130
+ startTime = kCMTimeZero;
131
+ effectiveDuration = targetDuration;
132
+ }
133
+
134
+ CMTimeRange trimRange = CMTimeRangeMake(startTime, effectiveDuration);
135
+
136
+ NSString *extension = path.pathExtension.lowercaseString;
137
+ NSString *tempPath = [[path stringByDeletingPathExtension]
138
+ stringByAppendingFormat:@"_trim.tmp.%@", extension.length > 0 ? extension : @"mov"];
139
+ [[NSFileManager defaultManager] removeItemAtPath:tempPath error:nil];
140
+
141
+ AVAssetExportSession *exportSession = [[AVAssetExportSession alloc] initWithAsset:asset
142
+ presetName:AVAssetExportPresetPassthrough];
143
+ if (!exportSession) {
144
+ return;
145
+ }
146
+ exportSession.timeRange = trimRange;
147
+ exportSession.outputURL = [NSURL fileURLWithPath:tempPath];
148
+
149
+ NSString *fileType = AVFileTypeQuickTimeMovie;
150
+ if ([extension isEqualToString:@"mp4"]) {
151
+ fileType = AVFileTypeMPEG4;
152
+ } else if ([extension isEqualToString:@"mov"]) {
153
+ fileType = AVFileTypeQuickTimeMovie;
154
+ }
155
+ exportSession.outputFileType = fileType;
156
+
157
+ if (startTrimSeconds > 0.0) {
158
+ MRLog(@"✂️ Trimming camera head by %.3f s (target duration %.3f s) for %@", startTrimSeconds, stopLimitSeconds, path);
159
+ } else {
160
+ MRLog(@"✂️ Trimming %@ to %.3f seconds", path, stopLimitSeconds);
161
+ }
162
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
163
+ [exportSession exportAsynchronouslyWithCompletionHandler:^{
164
+ dispatch_semaphore_signal(semaphore);
165
+ }];
166
+ dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC));
167
+ dispatch_semaphore_wait(semaphore, timeout);
168
+
169
+ if (exportSession.status == AVAssetExportSessionStatusCompleted) {
170
+ NSError *removeError = nil;
171
+ [[NSFileManager defaultManager] removeItemAtPath:path error:&removeError];
172
+ if (removeError && removeError.code != NSFileNoSuchFileError) {
173
+ MRLog(@"⚠️ Failed removing original camera file before trim replace: %@", removeError);
174
+ }
175
+ NSError *moveError = nil;
176
+ if (![[NSFileManager defaultManager] moveItemAtPath:tempPath toPath:path error:&moveError]) {
177
+ MRLog(@"⚠️ Failed to replace camera file with trimmed version: %@", moveError);
178
+ } else {
179
+ MRLog(@"✅ Camera file trimmed to %.3f seconds", stopLimitSeconds);
180
+ }
181
+ } else {
182
+ [[NSFileManager defaultManager] removeItemAtPath:tempPath error:nil];
183
+ if (exportSession.error) {
184
+ MRLog(@"⚠️ Camera trim export failed: %@", exportSession.error);
185
+ } else {
186
+ MRLog(@"⚠️ Camera trim export did not complete (status %ld)", (long)exportSession.status);
187
+ }
188
+ }
189
+ }
190
+
74
191
  // Dedicated camera recorder used alongside screen capture
75
- @interface CameraRecorder : NSObject<AVCaptureVideoDataOutputSampleBufferDelegate>
192
+ // ELECTRON FIX: Using MovieFileOutput instead of VideoDataOutput to avoid buffer conflicts
193
+ @interface CameraRecorder : NSObject<AVCaptureFileOutputRecordingDelegate>
76
194
 
77
195
  @property (nonatomic, strong) AVCaptureSession *session;
78
196
  @property (nonatomic, strong) AVCaptureDeviceInput *deviceInput;
79
- @property (nonatomic, strong) AVCaptureVideoDataOutput *videoOutput;
80
- @property (nonatomic, strong) AVAssetWriter *assetWriter;
81
- @property (nonatomic, strong) AVAssetWriterInput *assetWriterInput;
82
- @property (nonatomic, strong) AVAssetWriterInputPixelBufferAdaptor *pixelBufferAdaptor;
83
- @property (nonatomic, strong) dispatch_queue_t captureQueue;
197
+ @property (nonatomic, strong) AVCaptureMovieFileOutput *movieFileOutput;
84
198
  @property (nonatomic, strong) NSString *outputPath;
85
199
  @property (atomic, assign) BOOL isRecording;
86
- @property (atomic, assign) BOOL writerStarted;
87
- @property (atomic, assign) BOOL isShuttingDown;
88
- @property (nonatomic, assign) CMTime firstSampleTime;
89
- @property (nonatomic, assign) int32_t expectedWidth;
90
- @property (nonatomic, assign) int32_t expectedHeight;
91
- @property (nonatomic, assign) double expectedFrameRate;
92
- @property (atomic, assign) BOOL needsReconfiguration;
93
- @property (nonatomic, strong) NSMutableArray<NSValue *> *pendingSampleBuffers;
200
+ @property (nonatomic, strong) dispatch_semaphore_t recordingStartedSemaphore;
201
+ @property (atomic, assign) BOOL recordingStartCompleted;
202
+ @property (atomic, assign) BOOL recordingStartSucceeded;
94
203
 
95
204
  + (instancetype)sharedRecorder;
96
205
  + (NSArray<NSDictionary *> *)availableCameraDevices;
@@ -106,11 +215,48 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
106
215
  - (instancetype)init {
107
216
  self = [super init];
108
217
  if (self) {
109
- _pendingSampleBuffers = [NSMutableArray array];
218
+ // MovieFileOutput-based recorder - no buffer management needed
219
+ _recordingStartCompleted = YES;
220
+ _recordingStartSucceeded = NO;
110
221
  }
111
222
  return self;
112
223
  }
113
224
 
225
+ - (void)prepareRecordingStartSignal {
226
+ self.recordingStartCompleted = NO;
227
+ self.recordingStartSucceeded = NO;
228
+ self.recordingStartedSemaphore = dispatch_semaphore_create(0);
229
+ }
230
+
231
+ - (void)finishRecordingStart:(BOOL)success {
232
+ if (self.recordingStartCompleted && self.recordingStartSucceeded == success) {
233
+ return;
234
+ }
235
+ self.recordingStartCompleted = YES;
236
+ self.recordingStartSucceeded = success;
237
+ dispatch_semaphore_t semaphore = self.recordingStartedSemaphore;
238
+ if (semaphore) {
239
+ dispatch_semaphore_signal(semaphore);
240
+ }
241
+ }
242
+
243
+ - (BOOL)waitForRecordingStartWithTimeout:(NSTimeInterval)timeout {
244
+ if (self.recordingStartCompleted) {
245
+ return self.recordingStartSucceeded;
246
+ }
247
+ dispatch_semaphore_t semaphore = self.recordingStartedSemaphore;
248
+ if (!semaphore) {
249
+ return self.recordingStartSucceeded;
250
+ }
251
+ dispatch_time_t waitTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC));
252
+ long result = dispatch_semaphore_wait(semaphore, waitTime);
253
+ if (result != 0 && !self.recordingStartCompleted) {
254
+ return NO;
255
+ }
256
+ return self.recordingStartSucceeded;
257
+ }
258
+
259
+
114
260
  + (instancetype)sharedRecorder {
115
261
  static CameraRecorder *recorder = nil;
116
262
  static dispatch_once_t onceToken;
@@ -265,36 +411,18 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
265
411
  return devicesInfo;
266
412
  }
267
413
 
414
+ // ELECTRON FIX: MovieFileOutput-based recorder doesn't need buffer management
415
+ // These methods are kept for compatibility but do nothing
268
416
  - (void)clearPendingSampleBuffers {
269
- id container = self.pendingSampleBuffers;
270
- if (![container isKindOfClass:[NSArray class]]) {
271
- MRLog(@"⚠️ CameraRecorder: pendingSampleBuffers corrupted (%@) — resetting", NSStringFromClass([container class]));
272
- self.pendingSampleBuffers = [NSMutableArray array];
273
- return;
274
- }
275
- for (NSValue *value in (NSArray *)container) {
276
- CMSampleBufferRef buffer = (CMSampleBufferRef)[value pointerValue];
277
- if (buffer) {
278
- CFRelease(buffer);
279
- }
280
- }
281
- [self.pendingSampleBuffers removeAllObjects];
417
+ // No-op: MovieFileOutput manages its own buffers
282
418
  }
283
419
 
284
420
  - (void)resetState {
285
- self.writerStarted = NO;
286
421
  self.isRecording = NO;
287
- self.isShuttingDown = NO;
288
- self.firstSampleTime = kCMTimeInvalid;
289
422
  self.session = nil;
290
423
  self.deviceInput = nil;
291
- self.videoOutput = nil;
292
- self.assetWriter = nil;
293
- self.assetWriterInput = nil;
294
- self.pixelBufferAdaptor = nil;
424
+ self.movieFileOutput = nil;
295
425
  self.outputPath = nil;
296
- self.captureQueue = nil;
297
- [self clearPendingSampleBuffers];
298
426
  }
299
427
 
300
428
  - (AVCaptureDevice *)deviceForId:(NSString *)deviceId {
@@ -332,8 +460,12 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
332
460
  continue;
333
461
  }
334
462
 
335
- // No filtering - use whatever the device supports
336
- // The device knows best what it can capture
463
+ // ELECTRON FIX: Limit resolution to 1280x720 (720p) for balance
464
+ // Full HD works fine with MovieFileOutput since it doesn't conflict with ScreenCaptureKit
465
+ // But we still cap at 720p for reasonable file sizes and performance
466
+ if (dims.width > 1280 || dims.height > 720) {
467
+ continue; // Skip formats higher than 720p
468
+ }
337
469
 
338
470
  int64_t score = (int64_t)dims.width * (int64_t)dims.height;
339
471
 
@@ -387,8 +519,10 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
387
519
  device.activeFormat = format;
388
520
  }
389
521
 
390
- // Clamp desired frame rate within supported ranges
391
- double targetFrameRate = frameRate > 0 ? frameRate : 30.0;
522
+ // ELECTRON FIX: Use reasonable frame rate (24 FPS) for good quality
523
+ // MovieFileOutput works perfectly with ScreenCaptureKit - no conflicts!
524
+ // 24fps provides smooth motion while keeping file size reasonable
525
+ double targetFrameRate = frameRate > 0 ? MIN(frameRate, 24.0) : 24.0;
392
526
  AVFrameRateRange *bestRange = nil;
393
527
  for (AVFrameRateRange *range in format.videoSupportedFrameRateRanges) {
394
528
  if (!bestRange || range.maxFrameRate > bestRange.maxFrameRate) {
@@ -432,6 +566,7 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
432
566
  return YES;
433
567
  }
434
568
 
569
+ #if 0 // ELECTRON FIX: Old buffer management code - no longer used with MovieFileOutput
435
570
  - (BOOL)setupWriterWithURL:(NSURL *)outputURL
436
571
  width:(int32_t)width
437
572
  height:(int32_t)height
@@ -569,9 +704,12 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
569
704
  [self.assetWriter addInput:self.assetWriterInput];
570
705
  self.writerStarted = NO;
571
706
  self.firstSampleTime = kCMTimeInvalid;
572
-
707
+
573
708
  return YES;
574
709
  }
710
+ #endif // End old buffer management code (setupWriterWithURL)
711
+
712
+ // MARK: - NEW MovieFileOutput Implementation (NOT disabled!)
575
713
 
576
714
  - (BOOL)startRecordingWithDeviceId:(NSString *)deviceId
577
715
  outputPath:(NSString *)outputPath
@@ -596,119 +734,157 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
596
734
  return NO;
597
735
  }
598
736
 
599
- // Ensure camera permission
600
- __block BOOL cameraPermissionGranted = YES;
737
+ // CRITICAL ELECTRON FIX: Non-blocking permission check
601
738
  AVAuthorizationStatus cameraStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
602
- if (cameraStatus == AVAuthorizationStatusNotDetermined) {
603
- dispatch_semaphore_t permissionSemaphore = dispatch_semaphore_create(0);
604
- [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
605
- cameraPermissionGranted = granted;
606
- dispatch_semaphore_signal(permissionSemaphore);
607
- }];
608
- dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC));
609
- dispatch_semaphore_wait(permissionSemaphore, timeout);
610
- } else if (cameraStatus != AVAuthorizationStatusAuthorized) {
611
- cameraPermissionGranted = NO;
612
- }
613
739
 
614
- if (!cameraPermissionGranted) {
740
+ // Definitely denied - stop immediately
741
+ if (cameraStatus == AVAuthorizationStatusDenied || cameraStatus == AVAuthorizationStatusRestricted) {
615
742
  if (error) {
616
- NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: @"Camera permission not granted" };
743
+ NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: @"Camera permission denied - please grant permission in System Settings" };
617
744
  *error = [NSError errorWithDomain:@"CameraRecorder" code:-4 userInfo:userInfo];
618
745
  }
619
746
  return NO;
620
747
  }
621
748
 
622
- // Remove any stale file
623
- NSError *removeError = nil;
624
- [[NSFileManager defaultManager] removeItemAtPath:outputPath error:&removeError];
625
- if (removeError && removeError.code != NSFileNoSuchFileError) {
626
- MRLog(@"⚠️ CameraRecorder: Failed to remove existing camera file: %@", removeError);
627
- }
628
-
629
- [self clearPendingSampleBuffers];
630
- AVCaptureDevice *device = [self deviceForId:deviceId];
631
- if (!device) {
632
- if (error) {
633
- NSDictionary *userInfo = @{
634
- NSLocalizedDescriptionKey: @"No camera devices available"
635
- };
636
- *error = [NSError errorWithDomain:@"CameraRecorder" code:-3 userInfo:userInfo];
637
- }
638
- return NO;
749
+ // CRITICAL ELECTRON FIX: For NotDetermined, request async and assume it will be granted
750
+ // Blocking/polling here causes Electron crashes
751
+ if (cameraStatus == AVAuthorizationStatusNotDetermined) {
752
+ MRLog(@"🔐 Camera permission not determined - requesting async (non-blocking)...");
753
+ [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
754
+ if (granted) {
755
+ MRLog(@"✅ Camera permission granted (async callback)");
756
+ } else {
757
+ MRLog(@"❌ Camera permission denied (async callback)");
758
+ }
759
+ }];
760
+ // Don't wait - camera will start when permission is granted
761
+ MRLog(@"📤 Permission request sent, continuing without blocking...");
639
762
  }
640
763
 
641
- if (MRIsContinuityCamera(device) && !MRAllowContinuityCamera()) {
642
- if (error) {
643
- NSDictionary *userInfo = @{
644
- NSLocalizedDescriptionKey: @"Continuity Camera requires NSCameraUseContinuityCameraDeviceType=true in Info.plist"
645
- };
646
- *error = [NSError errorWithDomain:@"CameraRecorder" code:-5 userInfo:userInfo];
764
+ // CRITICAL ELECTRON FIX: Do ALL potentially blocking operations on background thread
765
+ // File I/O and device locking MUST NOT block main thread
766
+
767
+ // Store output path for later use
768
+ self.outputPath = outputPath;
769
+ self.isRecording = YES; // Mark as recording early
770
+ [self prepareRecordingStartSignal];
771
+
772
+ // Schedule all blocking operations on background thread
773
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
774
+ @autoreleasepool {
775
+ // Remove any stale file (BLOCKING I/O - safe on background thread)
776
+ NSError *removeError = nil;
777
+ [[NSFileManager defaultManager] removeItemAtPath:outputPath error:&removeError];
778
+ if (removeError && removeError.code != NSFileNoSuchFileError) {
779
+ MRLog(@"⚠️ CameraRecorder: Failed to remove existing camera file: %@", removeError);
780
+ }
781
+
782
+ [self clearPendingSampleBuffers];
783
+ AVCaptureDevice *device = [self deviceForId:deviceId];
784
+ if (!device) {
785
+ MRLog(@"❌ No camera devices available");
786
+ self.isRecording = NO;
787
+ [self finishRecordingStart:NO];
788
+ return;
789
+ }
790
+
791
+ if (MRIsContinuityCamera(device) && !MRAllowContinuityCamera()) {
792
+ MRLog(@"⚠️ Continuity Camera access denied - missing Info.plist entitlement");
793
+ self.isRecording = NO;
794
+ [self finishRecordingStart:NO];
795
+ return;
796
+ }
797
+
798
+ int32_t width = 0;
799
+ int32_t height = 0;
800
+ double frameRate = 0.0;
801
+ AVCaptureDeviceFormat *bestFormat = [self bestFormatForDevice:device widthOut:&width heightOut:&height frameRateOut:&frameRate];
802
+
803
+ NSError *configError = nil;
804
+ if (![self configureDevice:device withFormat:bestFormat frameRate:frameRate error:&configError]) {
805
+ MRLog(@"❌ Failed to configure device: %@", configError);
806
+ self.isRecording = NO;
807
+ [self finishRecordingStart:NO];
808
+ return;
809
+ }
810
+
811
+ // Continue with session setup on background thread
812
+ [self continueSetupWithDevice:device width:width height:height frameRate:frameRate outputPath:outputPath];
647
813
  }
648
- MRLog(@"⚠️ Continuity Camera access denied - missing Info.plist entitlement");
649
- return NO;
650
- }
651
-
652
- int32_t width = 0;
653
- int32_t height = 0;
654
- double frameRate = 0.0;
655
- AVCaptureDeviceFormat *bestFormat = [self bestFormatForDevice:device widthOut:&width heightOut:&height frameRateOut:&frameRate];
656
-
657
- if (![self configureDevice:device withFormat:bestFormat frameRate:frameRate error:error]) {
658
- return NO;
659
- }
660
-
814
+ });
815
+
816
+ // Return immediately - setup continues async
817
+ MRLog(@"📤 Camera setup scheduled on background thread (non-blocking)");
818
+ return YES;
819
+ }
820
+
821
+ - (void)continueSetupWithDevice:(AVCaptureDevice *)device
822
+ width:(int32_t)width
823
+ height:(int32_t)height
824
+ frameRate:(double)frameRate
825
+ outputPath:(NSString *)outputPath {
826
+
661
827
  self.session = [[AVCaptureSession alloc] init];
662
-
663
- self.deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:error];
828
+
829
+ // ELECTRON FIX: Use HIGH preset for good quality
830
+ // MovieFileOutput works perfectly with ScreenCaptureKit - no more crashes!
831
+ // We already selected best format (up to 720p), so HIGH preset will use it
832
+ self.session.sessionPreset = AVCaptureSessionPresetHigh;
833
+ MRLog(@"📹 Camera session preset: HIGH (up to 720p based on selected format)");
834
+
835
+ // CRITICAL ELECTRON FIX: Use beginConfiguration / commitConfiguration
836
+ // This makes session configuration ATOMIC and prevents crashes
837
+ [self.session beginConfiguration];
838
+
839
+ NSError *localError = nil;
840
+ self.deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:&localError];
664
841
  if (!self.deviceInput) {
842
+ MRLog(@"❌ Failed to create device input: %@", localError);
843
+ [self.session commitConfiguration];
665
844
  [self resetState];
666
- return NO;
845
+ [self finishRecordingStart:NO];
846
+ return;
667
847
  }
668
-
848
+
669
849
  if ([self.session canAddInput:self.deviceInput]) {
670
850
  [self.session addInput:self.deviceInput];
671
851
  } else {
672
- if (error) {
673
- NSDictionary *userInfo = @{
674
- NSLocalizedDescriptionKey: @"Unable to add camera input to capture session"
675
- };
676
- *error = [NSError errorWithDomain:@"CameraRecorder" code:-6 userInfo:userInfo];
677
- }
852
+ MRLog(@"❌ Unable to add camera input to capture session");
853
+ [self.session commitConfiguration];
678
854
  [self resetState];
679
- return NO;
855
+ [self finishRecordingStart:NO];
856
+ return;
680
857
  }
681
-
682
- self.videoOutput = [[AVCaptureVideoDataOutput alloc] init];
683
- self.videoOutput.alwaysDiscardsLateVideoFrames = NO;
684
- // Use video range (not full range) for better compatibility and quality
685
- // YpCbCr 4:2:0 biplanar is the native format for most cameras
686
- self.videoOutput.videoSettings = @{
687
- (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
688
- };
689
-
690
- self.captureQueue = dispatch_queue_create("node_mac_recorder.camera.queue", DISPATCH_QUEUE_SERIAL);
691
- [self.videoOutput setSampleBufferDelegate:self queue:self.captureQueue];
692
-
693
- if ([self.session canAddOutput:self.videoOutput]) {
694
- [self.session addOutput:self.videoOutput];
858
+
859
+ // CRITICAL ELECTRON FIX: Use AVCaptureMovieFileOutput instead of VideoDataOutput
860
+ // VideoDataOutput uses frame-by-frame buffers that conflict with ScreenCaptureKit
861
+ // MovieFileOutput writes directly to file with different buffer management
862
+ self.movieFileOutput = [[AVCaptureMovieFileOutput alloc] init];
863
+
864
+ if ([self.session canAddOutput:self.movieFileOutput]) {
865
+ [self.session addOutput:self.movieFileOutput];
866
+ MRLog(@"✅ Added AVCaptureMovieFileOutput (file-based recording, no buffer conflicts)");
695
867
  } else {
696
- if (error) {
697
- NSDictionary *userInfo = @{
698
- NSLocalizedDescriptionKey: @"Unable to add camera output to capture session"
699
- };
700
- *error = [NSError errorWithDomain:@"CameraRecorder" code:-7 userInfo:userInfo];
701
- }
868
+ MRLog(@"❌ Unable to add movie file output to capture session");
869
+ [self.session commitConfiguration];
702
870
  [self resetState];
703
- return NO;
871
+ [self finishRecordingStart:NO];
872
+ return;
873
+ }
874
+
875
+ // CRITICAL FIX: Disable audio recording - we only want video from camera
876
+ // MovieFileOutput by default tries to record both audio and video
877
+ // Since we don't have an audio input, it won't start recording at all
878
+ AVCaptureConnection *audioConnection = [self.movieFileOutput connectionWithMediaType:AVMediaTypeAudio];
879
+ if (audioConnection) {
880
+ audioConnection.enabled = NO;
881
+ MRLog(@"🔇 Disabled audio connection (video-only recording)");
882
+ } else {
883
+ MRLog(@"ℹ️ No audio connection found (expected - video-only setup)");
704
884
  }
705
-
706
- AVCaptureConnection *connection = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo];
707
- if (connection) {
708
- // DON'T set orientation - let the camera use its natural orientation
709
- // The device knows best (portrait for phones, landscape for webcams)
710
- // We just capture whatever comes through
711
885
 
886
+ AVCaptureConnection *connection = [self.movieFileOutput connectionWithMediaType:AVMediaTypeVideo];
887
+ if (connection) {
712
888
  // Mirror front cameras for natural preview
713
889
  if (connection.isVideoMirroringSupported && device.position == AVCaptureDevicePositionFront) {
714
890
  if ([connection respondsToSelector:@selector(setAutomaticallyAdjustsVideoMirroring:)]) {
@@ -716,148 +892,159 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
716
892
  }
717
893
  connection.videoMirrored = YES;
718
894
  }
719
-
720
- // Log actual connection properties for debugging
721
895
  MRLog(@"📐 Camera connection: orientation=%ld (native), mirrored=%d, format=%dx%d",
722
896
  (long)connection.videoOrientation,
723
897
  connection.isVideoMirrored,
724
898
  width, height);
725
899
  }
726
-
727
- // DON'T setup writer yet - wait for first frame to get actual dimensions
728
- // Store configuration for lazy initialization
900
+
901
+ // CRITICAL: Commit configuration BEFORE starting
902
+ [self.session commitConfiguration];
903
+
904
+ // Store configuration
729
905
  self.outputPath = outputPath;
730
906
  self.isRecording = YES;
731
- self.isShuttingDown = NO;
732
- self.expectedWidth = width;
733
- self.expectedHeight = height;
734
- self.expectedFrameRate = frameRate;
735
- self.needsReconfiguration = NO;
736
907
 
908
+ // CRITICAL ELECTRON FIX: Start on current thread (already on background)
909
+ // Don't touch main thread at all - AVCaptureSession can start on background thread
910
+ MRLog(@"🎥 Starting AVCaptureSession (camera) on background thread...");
737
911
  [self.session startRunning];
738
912
 
739
- MRLog(@"🎥 CameraRecorder started: %@ (will use actual frame dimensions)", device.localizedName);
740
- MRLog(@" Format reports: %dx%d @ %.2ffps", width, height, frameRate);
741
- return YES;
742
- }
913
+ // Wait a moment for session to fully start
914
+ [NSThread sleepForTimeInterval:0.5];
743
915
 
744
- - (BOOL)stopRecording {
745
- if (!self.isRecording) {
746
- return YES;
916
+ MRLog(@"✅ AVCaptureSession started");
917
+ MRLog(@"🔍 Session isRunning: %d", [self.session isRunning]);
918
+
919
+ // CRITICAL FIX: MovieFileOutput only supports .mov and .mp4, NOT .webm
920
+ // If path ends with .webm, change it to .mov
921
+ NSString *finalOutputPath = outputPath;
922
+ if ([outputPath.pathExtension.lowercaseString isEqualToString:@"webm"]) {
923
+ finalOutputPath = [[outputPath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mov"];
924
+ MRLog(@"⚠️ Camera: Changed output from .webm to .mov (MovieFileOutput doesn't support WebM)");
925
+ MRLog(@" New path: %@", finalOutputPath);
926
+ // Update stored path
927
+ self.outputPath = finalOutputPath;
747
928
  }
748
929
 
749
- // Delay stop slightly so camera ends close to audio length.
750
- // SYNC FIX: Optimized tail seconds for audio/camera sync
751
- // This compensates for camera cold-start delay and trailing frame capture
752
- // Tunable via env var CAMERA_TAIL_SECONDS (default 0.55s for optimal sync)
753
- NSTimeInterval cameraTailSeconds = 0.55;
754
- const char *tailEnv = getenv("CAMERA_TAIL_SECONDS");
755
- if (tailEnv) {
756
- double parsed = atof(tailEnv);
757
- if (parsed >= 0.0 && parsed <= 2.0) {
758
- cameraTailSeconds = parsed;
759
- }
930
+ // CRITICAL: Start file recording immediately
931
+ NSURL *outputURL = [NSURL fileURLWithPath:finalOutputPath];
932
+
933
+ // Verify path and output are valid
934
+ if (!outputURL) {
935
+ MRLog(@"❌ Failed to create output URL from path: %@", finalOutputPath);
936
+ [self finishRecordingStart:NO];
937
+ return;
760
938
  }
761
- if (cameraTailSeconds > 0) {
762
- MRLog(@"⏳ CameraRecorder: Delaying stop by %.3fs for tail capture", cameraTailSeconds);
763
- [NSThread sleepForTimeInterval:cameraTailSeconds];
764
- }
765
-
766
- // CRITICAL FIX: For external cameras (especially Continuity Camera/iPhone),
767
- // stopRunning can hang if device is disconnected. Use async approach.
768
- MRLog(@"🛑 CameraRecorder: Stopping session (external device safe)...");
769
-
770
- self.isShuttingDown = YES;
771
-
772
- // Stop session on background thread to avoid blocking, but wait briefly so we capture trailing frames.
773
- AVCaptureSession *sessionToStop = self.session;
774
-
775
- dispatch_group_t sessionStopGroup = NULL;
776
- if (sessionToStop) {
777
- sessionStopGroup = dispatch_group_create();
778
- dispatch_group_enter(sessionStopGroup);
779
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
780
- @autoreleasepool {
781
- @try {
782
- if ([sessionToStop isRunning]) {
783
- MRLog(@"🛑 Stopping AVCaptureSession (camera)...");
784
- [sessionToStop stopRunning];
785
- MRLog(@"✅ AVCaptureSession stopped (camera)");
786
- }
787
- } @catch (NSException *exception) {
788
- MRLog(@"⚠️ CameraRecorder: Exception while stopping session: %@", exception.reason);
789
- }
790
- dispatch_group_leave(sessionStopGroup);
791
- }
792
- });
939
+
940
+ if (!self.movieFileOutput) {
941
+ MRLog(@"❌ movieFileOutput is nil!");
942
+ return;
793
943
  }
794
944
 
795
- if (sessionStopGroup) {
796
- dispatch_time_t sessionTimeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC));
797
- long waitResult = dispatch_group_wait(sessionStopGroup, sessionTimeout);
798
- if (waitResult != 0) {
799
- MRLog(@"⚠️ CameraRecorder: AVCaptureSession stop timed out after 2s (continuing shutdown)");
945
+ MRLog(@"📁 Camera output path: %@", outputPath);
946
+ MRLog(@"📁 Camera output URL: %@", outputURL);
947
+ MRLog(@"🎥 Starting movie file recording...");
948
+
949
+ [self.movieFileOutput startRecordingToOutputFileURL:outputURL recordingDelegate:self];
950
+
951
+ MRLog(@"✅ startRecordingToOutputFileURL called");
952
+ MRLog(@"🔍 Is recording: %d", [self.movieFileOutput isRecording]);
953
+
954
+ MRLog(@"🎥 CameraRecorder started: %@ (file-based recording)", device.localizedName);
955
+ MRLog(@" Format reports: %dx%d @ %.2ffps", width, height, frameRate);
956
+ }
957
+
958
+ // MARK: - AVCaptureFileOutputRecordingDelegate
959
+
960
+ - (void)captureOutput:(AVCaptureFileOutput *)output
961
+ didFinishRecordingToOutputFileAtURL:(NSURL *)outputFileURL
962
+ fromConnections:(NSArray<AVCaptureConnection *> *)connections
963
+ error:(NSError *)error {
964
+ MRLog(@"🎬 DELEGATE: didFinishRecordingToOutputFileAtURL called");
965
+ MRLog(@" File URL: %@", outputFileURL.path);
966
+
967
+ if (error) {
968
+ MRLog(@"❌ Camera recording finished with ERROR:");
969
+ MRLog(@" Error code: %ld", (long)error.code);
970
+ MRLog(@" Error domain: %@", error.domain);
971
+ MRLog(@" Error description: %@", error.localizedDescription);
972
+ if (error.userInfo) {
973
+ MRLog(@" Error userInfo: %@", error.userInfo);
800
974
  }
801
- }
975
+ } else {
976
+ MRLog(@"✅ Camera recording finished SUCCESSFULLY");
977
+ MRLog(@" Output file: %@", outputFileURL.path);
802
978
 
803
- // Now detach delegate to prevent further callbacks
804
- if (self.videoOutput) {
805
- [self.videoOutput setSampleBufferDelegate:nil queue:nil];
806
- }
807
- self.isRecording = NO;
979
+ // Check if file actually exists
980
+ BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:outputFileURL.path];
981
+ MRLog(@" File exists on disk: %d", fileExists);
808
982
 
809
- // CRITICAL FIX: Check if assetWriter exists before trying to finish it
810
- // If no frames were captured, assetWriter will be nil
811
- if (!self.assetWriter) {
812
- MRLog(@"⚠️ CameraRecorder: No writer to finish (no frames captured)");
813
- [self resetState];
814
- return YES; // Success - nothing to finish
983
+ if (fileExists) {
984
+ NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:outputFileURL.path error:nil];
985
+ unsigned long long fileSize = [attrs fileSize];
986
+ MRLog(@" File size: %llu bytes", fileSize);
987
+ }
815
988
  }
816
989
 
817
- AVAssetWriter *writer = self.assetWriter;
818
- AVAssetWriterInput *writerInput = self.assetWriterInput;
819
-
820
- if (writerInput) {
821
- [writerInput markAsFinished];
990
+ double stopLimit = MRActiveStopLimitSeconds();
991
+ double screenStart = MRScreenRecordingStartTimestampSeconds();
992
+ double cameraStart = currentCameraRecordingStartTime();
993
+ double headTrim = 0.0;
994
+ if (screenStart > 0 && cameraStart > 0 && cameraStart < screenStart) {
995
+ headTrim = screenStart - cameraStart;
996
+ }
997
+ if (stopLimit > 0 || headTrim > 0) {
998
+ MRTrimMovieFileIfNeeded(outputFileURL.path, stopLimit, headTrim);
822
999
  }
1000
+ g_cameraStartTimestamp = 0.0;
823
1001
 
824
- __block BOOL finished = NO;
825
- dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
1002
+ self.isRecording = NO;
1003
+ }
826
1004
 
827
- [writer finishWritingWithCompletionHandler:^{
828
- finished = YES;
829
- dispatch_semaphore_signal(semaphore);
830
- }];
1005
+ - (void)captureOutput:(AVCaptureFileOutput *)output
1006
+ didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
1007
+ fromConnections:(NSArray<AVCaptureConnection *> *)connections {
1008
+ MRLog(@"🎬 DELEGATE: didStartRecordingToOutputFileAtURL called");
1009
+ MRLog(@" File URL: %@", fileURL.path);
1010
+ MRLog(@" Connections count: %lu", (unsigned long)connections.count);
1011
+ MRLog(@"✅ Camera file recording STARTED successfully!");
1012
+ [self finishRecordingStart:YES];
1013
+ }
831
1014
 
832
- // Allow generous flush time so final frames are preserved.
833
- const int64_t primaryWaitSeconds = 3;
834
- dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(primaryWaitSeconds * NSEC_PER_SEC));
835
- long result = dispatch_semaphore_wait(semaphore, timeout);
1015
+ - (BOOL)stopRecording {
1016
+ if (!self.isRecording) {
1017
+ return YES;
1018
+ }
836
1019
 
837
- if (result != 0 || !finished) {
838
- MRLog(@"⚠️ CameraRecorder: Writer still finishing after %ds – waiting longer", (int)primaryWaitSeconds);
839
- const int64_t extendedWaitSeconds = 5;
840
- dispatch_time_t extendedTimeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(extendedWaitSeconds * NSEC_PER_SEC));
841
- result = dispatch_semaphore_wait(semaphore, extendedTimeout);
1020
+ MRLog(@"🛑 CameraRecorder: Stopping recording...");
1021
+ if (!self.recordingStartCompleted) {
1022
+ [self finishRecordingStart:NO];
842
1023
  }
843
1024
 
844
- if (result != 0 || !finished) {
845
- MRLog(@"⚠️ CameraRecorder: Writer did not finish after extended wait – forcing cancel");
846
- [writer cancelWriting];
1025
+ // CRITICAL ELECTRON FIX: Stop movie file output (simple API)
1026
+ if (self.movieFileOutput && [self.movieFileOutput isRecording]) {
1027
+ [self.movieFileOutput stopRecording];
1028
+ MRLog(@"✅ Movie file output stop requested");
847
1029
  }
848
1030
 
849
- BOOL success = (writer.status == AVAssetWriterStatusCompleted);
850
- if (!success) {
851
- MRLog(@"⚠️ CameraRecorder: Writer finished with status %ld error %@", (long)writer.status, writer.error);
852
- } else {
853
- MRLog(@"✅ CameraRecorder writer finished successfully");
1031
+ // Stop session
1032
+ if (self.session && [self.session isRunning]) {
1033
+ [self.session stopRunning];
1034
+ MRLog(@"✅ AVCaptureSession stopped");
854
1035
  }
855
1036
 
856
- [self resetState];
857
- MRLog(@"✅ CameraRecorder stopped (safe for external devices)");
858
- return success;
1037
+ // Cleanup
1038
+ self.session = nil;
1039
+ self.deviceInput = nil;
1040
+ self.movieFileOutput = nil;
1041
+ self.outputPath = nil;
1042
+
1043
+ MRLog(@"✅ CameraRecorder stopped successfully");
1044
+ return YES;
859
1045
  }
860
1046
 
1047
+ #if 0 // ELECTRON FIX: Old buffer management code - no longer used with MovieFileOutput
861
1048
  - (void)enqueueSampleBuffer:(CMSampleBufferRef)sampleBuffer {
862
1049
  if (!sampleBuffer) {
863
1050
  return;
@@ -922,15 +1109,9 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
922
1109
  if (CMTIME_IS_VALID(baseline)) {
923
1110
  frameSeconds = CMTimeGetSeconds(CMTimeSubtract(bufferTime, baseline));
924
1111
  }
925
- // Adjust camera stop limit by start offset relative to audio
1112
+ // Do NOT extend camera stop limit by audio start offset.
1113
+ // Clamping to the same stopLimit as audio ensures durations match.
926
1114
  double effectiveStopLimit = stopLimit;
927
- if (hasAudioStart && CMTIME_IS_VALID(baseline)) {
928
- CMTime startDeltaTime = CMTimeSubtract(baseline, audioStart);
929
- double startDelta = CMTimeGetSeconds(startDeltaTime);
930
- if (startDelta > 0) {
931
- effectiveStopLimit += startDelta;
932
- }
933
- }
934
1115
  double tolerance = self.expectedFrameRate > 0 ? (1.5 / self.expectedFrameRate) : 0.02;
935
1116
  if (tolerance < 0.02) {
936
1117
  tolerance = 0.02;
@@ -1012,26 +1193,28 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
1012
1193
  self.firstSampleTime = timestamp;
1013
1194
  }
1014
1195
 
1015
- CMTime relativeTimestamp = timestamp;
1016
- if (CMTIME_IS_VALID(self.firstSampleTime)) {
1017
- relativeTimestamp = CMTimeSubtract(timestamp, self.firstSampleTime);
1196
+ CMTime baseline = kCMTimeInvalid;
1197
+ CMTime audioStart = MRSyncAudioFirstTimestamp();
1198
+ if (CMTIME_IS_VALID(audioStart)) {
1199
+ baseline = audioStart;
1200
+ } else if (CMTIME_IS_VALID(self.firstSampleTime)) {
1201
+ baseline = self.firstSampleTime;
1202
+ }
1203
+
1204
+ CMTime relativeTimestamp = kCMTimeZero;
1205
+ if (CMTIME_IS_VALID(baseline)) {
1206
+ relativeTimestamp = CMTimeSubtract(timestamp, baseline);
1018
1207
  if (CMTIME_COMPARE_INLINE(relativeTimestamp, <, kCMTimeZero)) {
1019
1208
  relativeTimestamp = kCMTimeZero;
1020
1209
  }
1210
+ } else {
1211
+ relativeTimestamp = timestamp;
1021
1212
  }
1022
1213
 
1023
1214
  double stopLimit = MRSyncGetStopLimitSeconds();
1024
1215
  if (stopLimit > 0) {
1025
- // Adjust by camera start vs audio start so durations align closely
1026
- CMTime audioStartTS = MRSyncAudioFirstTimestamp();
1027
- if (CMTIME_IS_VALID(audioStartTS) && CMTIME_IS_VALID(self.firstSampleTime)) {
1028
- CMTime startDeltaTS = CMTimeSubtract(self.firstSampleTime, audioStartTS);
1029
- double startDelta = CMTimeGetSeconds(startDeltaTS);
1030
- if (startDelta > 0) {
1031
- stopLimit += startDelta;
1032
- }
1033
- }
1034
-
1216
+ // Do NOT extend camera stop limit by audio start offset.
1217
+ // Using the same stopLimit as audio keeps durations aligned.
1035
1218
  double frameSeconds = CMTimeGetSeconds(relativeTimestamp);
1036
1219
  double tolerance = self.expectedFrameRate > 0 ? (1.5 / self.expectedFrameRate) : 0.02;
1037
1220
  if (tolerance < 0.02) {
@@ -1088,6 +1271,7 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
1088
1271
 
1089
1272
  [self processSampleBufferReadyForWriting:sampleBuffer];
1090
1273
  }
1274
+ #endif // End old buffer management code
1091
1275
 
1092
1276
  @end
1093
1277
 
@@ -1105,6 +1289,14 @@ bool startCameraRecording(NSString *outputPath, NSString *deviceId, NSError **er
1105
1289
  error:error];
1106
1290
  }
1107
1291
 
1292
+ bool waitForCameraRecordingStart(double timeoutSeconds) {
1293
+ return [[CameraRecorder sharedRecorder] waitForRecordingStartWithTimeout:timeoutSeconds];
1294
+ }
1295
+
1296
+ double currentCameraRecordingStartTime(void) {
1297
+ return g_cameraStartTimestamp;
1298
+ }
1299
+
1108
1300
  bool stopCameraRecording() {
1109
1301
  @autoreleasepool {
1110
1302
  return [[CameraRecorder sharedRecorder] stopRecording];