node-mac-recorder 2.21.40 โ†’ 2.21.42

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.
@@ -6,40 +6,310 @@
6
6
  #import <CoreMedia/CoreMedia.h>
7
7
  #import <AudioToolbox/AudioToolbox.h>
8
8
 
9
- // Pure ScreenCaptureKit implementation - NO AVFoundation
9
+ // MULTI-SESSION RECORDING: Session-based state management
10
+ @interface RecordingSession : NSObject
11
+ @property (nonatomic, strong) NSString *sessionId;
12
+ @property (nonatomic, strong) SCStream *stream API_AVAILABLE(macos(12.3));
13
+ @property (nonatomic, strong) id<SCStreamDelegate> streamDelegate API_AVAILABLE(macos(12.3));
14
+ @property (nonatomic, assign) BOOL isRecording;
15
+ @property (nonatomic, assign) BOOL isCleaningUp;
16
+ @property (nonatomic, assign) BOOL isScheduling;
17
+ @property (nonatomic, strong) NSString *outputPath;
18
+
19
+ // Frame tracking
20
+ @property (nonatomic, assign) BOOL firstFrameReceived;
21
+ @property (nonatomic, assign) NSInteger frameCountSinceStart;
22
+
23
+ // Queues and outputs
24
+ @property (nonatomic, strong) dispatch_queue_t videoQueue;
25
+ @property (nonatomic, strong) dispatch_queue_t audioQueue;
26
+ @property (nonatomic, strong) id videoStreamOutput;
27
+ @property (nonatomic, strong) id audioStreamOutput;
28
+
29
+ // Video writer state
30
+ @property (nonatomic, strong) AVAssetWriter *videoWriter;
31
+ @property (nonatomic, strong) AVAssetWriterInput *videoInput;
32
+ @property (nonatomic, assign) CFTypeRef pixelBufferAdaptorRef;
33
+ @property (nonatomic, assign) CMTime videoStartTime;
34
+ @property (nonatomic, assign) BOOL videoWriterStarted;
35
+
36
+ // Audio state
37
+ @property (nonatomic, assign) BOOL shouldCaptureAudio;
38
+ @property (nonatomic, strong) NSString *audioOutputPath;
39
+ @property (nonatomic, strong) AVAssetWriter *audioWriter;
40
+ @property (nonatomic, strong) AVAssetWriterInput *systemAudioInput;
41
+ @property (nonatomic, strong) AVAssetWriterInput *microphoneAudioInput;
42
+ @property (nonatomic, assign) CMTime audioStartTime;
43
+ @property (nonatomic, assign) BOOL audioWriterStarted;
44
+ @property (nonatomic, assign) BOOL captureMicrophoneEnabled;
45
+ @property (nonatomic, assign) BOOL captureSystemAudioEnabled;
46
+ @property (nonatomic, assign) BOOL mixAudioEnabled;
47
+ @property (nonatomic, assign) float mixMicGain;
48
+ @property (nonatomic, assign) float mixSystemGain;
49
+
50
+ // Configuration
51
+ @property (nonatomic, assign) NSInteger configuredSampleRate;
52
+ @property (nonatomic, assign) NSInteger configuredChannelCount;
53
+ @property (nonatomic, assign) NSInteger targetFPS;
54
+
55
+ // Frame rate debugging
56
+ @property (nonatomic, assign) NSInteger frameCount;
57
+ @property (nonatomic, assign) CFAbsoluteTime firstFrameTime;
58
+
59
+ - (instancetype)initWithSessionId:(NSString *)sessionId;
60
+ - (void)cleanup;
61
+ @end
62
+
63
+ @implementation RecordingSession
64
+
65
+ - (instancetype)initWithSessionId:(NSString *)sessionId {
66
+ self = [super init];
67
+ if (self) {
68
+ _sessionId = sessionId;
69
+ _isRecording = NO;
70
+ _isCleaningUp = NO;
71
+ _isScheduling = NO;
72
+ _firstFrameReceived = NO;
73
+ _frameCountSinceStart = 0;
74
+ _videoStartTime = kCMTimeInvalid;
75
+ _videoWriterStarted = NO;
76
+ _audioStartTime = kCMTimeInvalid;
77
+ _audioWriterStarted = NO;
78
+ _shouldCaptureAudio = NO;
79
+ _captureMicrophoneEnabled = NO;
80
+ _captureSystemAudioEnabled = NO;
81
+ _mixAudioEnabled = YES;
82
+ _mixMicGain = 0.8f;
83
+ _mixSystemGain = 0.4f;
84
+ _configuredSampleRate = 48000;
85
+ _configuredChannelCount = 2;
86
+ _targetFPS = 60;
87
+ _frameCount = 0;
88
+ _firstFrameTime = 0;
89
+ _pixelBufferAdaptorRef = NULL;
90
+ }
91
+ return self;
92
+ }
93
+
94
+ - (void)cleanup {
95
+ MRLog(@"๐Ÿงน Cleaning up session: %@", _sessionId);
96
+
97
+ if (_pixelBufferAdaptorRef) {
98
+ CFRelease(_pixelBufferAdaptorRef);
99
+ _pixelBufferAdaptorRef = NULL;
100
+ }
101
+
102
+ _stream = nil;
103
+ _streamDelegate = nil;
104
+ _videoWriter = nil;
105
+ _videoInput = nil;
106
+ _audioWriter = nil;
107
+ _systemAudioInput = nil;
108
+ _microphoneAudioInput = nil;
109
+ _videoStreamOutput = nil;
110
+ _audioStreamOutput = nil;
111
+ _videoQueue = nil;
112
+ _audioQueue = nil;
113
+ _outputPath = nil;
114
+ _audioOutputPath = nil;
115
+ _isRecording = NO;
116
+ _isCleaningUp = NO;
117
+ _isScheduling = NO;
118
+ _firstFrameReceived = NO;
119
+ _frameCountSinceStart = 0;
120
+ }
121
+
122
+ - (void)dealloc {
123
+ [self cleanup];
124
+ }
125
+
126
+ @end
127
+
128
+ // Session registry - thread-safe access
129
+ static NSMutableDictionary<NSString *, RecordingSession *> *g_sessions = nil;
130
+ static dispatch_queue_t g_sessionsQueue = nil;
131
+
132
+ // Legacy global state for backward compatibility (points to first/default session)
10
133
  static SCStream * API_AVAILABLE(macos(12.3)) g_stream = nil;
11
134
  static id<SCStreamDelegate> API_AVAILABLE(macos(12.3)) g_streamDelegate = nil;
12
135
  static BOOL g_isRecording = NO;
13
- static BOOL g_isCleaningUp = NO; // Prevent recursive cleanup
136
+ static BOOL g_isCleaningUp = NO;
137
+ static BOOL g_isScheduling = NO;
14
138
  static NSString *g_outputPath = nil;
15
-
139
+ static BOOL g_firstFrameReceived = NO;
140
+ static NSInteger g_frameCountSinceStart = 0;
16
141
  static dispatch_queue_t g_videoQueue = nil;
17
142
  static dispatch_queue_t g_audioQueue = nil;
18
143
  static id g_videoStreamOutput = nil;
19
144
  static id g_audioStreamOutput = nil;
20
-
21
145
  static AVAssetWriter *g_videoWriter = nil;
22
146
  static AVAssetWriterInput *g_videoInput = nil;
23
147
  static CFTypeRef g_pixelBufferAdaptorRef = NULL;
24
148
  static CMTime g_videoStartTime = kCMTimeInvalid;
25
149
  static BOOL g_videoWriterStarted = NO;
26
-
27
150
  static BOOL g_shouldCaptureAudio = NO;
28
151
  static NSString *g_audioOutputPath = nil;
29
152
  static AVAssetWriter *g_audioWriter = nil;
30
- static AVAssetWriterInput *g_audioInput = nil;
153
+ static AVAssetWriterInput *g_systemAudioInput = nil;
154
+ static AVAssetWriterInput *g_microphoneAudioInput = nil;
31
155
  static CMTime g_audioStartTime = kCMTimeInvalid;
32
156
  static BOOL g_audioWriterStarted = NO;
33
-
157
+ static BOOL g_captureMicrophoneEnabled = NO;
158
+ static BOOL g_captureSystemAudioEnabled = NO;
159
+ static BOOL g_mixAudioEnabled = YES;
160
+ static float g_mixMicGain = 0.8f;
161
+ static float g_mixSystemGain = 0.4f;
34
162
  static NSInteger g_configuredSampleRate = 48000;
35
163
  static NSInteger g_configuredChannelCount = 2;
36
164
  static NSInteger g_targetFPS = 60;
37
-
38
- // Frame rate debugging
165
+ static NSString *g_qualityPreset = @"high";
39
166
  static NSInteger g_frameCount = 0;
40
167
  static CFAbsoluteTime g_firstFrameTime = 0;
41
168
 
169
+ // Quality helpers
170
+ static NSString *SCKNormalizeQualityPreset(id preset) {
171
+ if (![preset isKindOfClass:[NSString class]]) {
172
+ return @"high";
173
+ }
174
+ NSString *trimmed = [[(NSString *)preset stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString];
175
+ if ([trimmed length] == 0) {
176
+ return @"high";
177
+ }
178
+ if ([trimmed isEqualToString:@"low"] || [trimmed isEqualToString:@"medium"] || [trimmed isEqualToString:@"high"]) {
179
+ return trimmed;
180
+ }
181
+ return @"high";
182
+ }
183
+
184
+ static CGFloat SCKQualityScaleForPreset(NSString *preset) {
185
+ NSString *normalized = SCKNormalizeQualityPreset(preset);
186
+ if ([normalized isEqualToString:@"low"]) {
187
+ return 0.5;
188
+ }
189
+ if ([normalized isEqualToString:@"medium"]) {
190
+ return 0.75;
191
+ }
192
+ return 1.0; // High = full resolution
193
+ }
194
+
195
+ static void SCKQualityBitrateForDimensions(NSString *preset,
196
+ NSInteger width,
197
+ NSInteger height,
198
+ NSInteger *bitrateOut,
199
+ NSInteger *multiplierOut,
200
+ NSInteger *minOut,
201
+ NSInteger *maxOut) {
202
+ NSString *normalized = SCKNormalizeQualityPreset(preset);
203
+
204
+ NSInteger multiplier = 30;
205
+ NSInteger minBitrate = 30 * 1000 * 1000;
206
+ NSInteger maxBitrate = 120 * 1000 * 1000;
207
+
208
+ if ([normalized isEqualToString:@"low"]) {
209
+ multiplier = 10;
210
+ minBitrate = 10 * 1000 * 1000;
211
+ maxBitrate = 45 * 1000 * 1000;
212
+ } else if ([normalized isEqualToString:@"medium"]) {
213
+ multiplier = 18;
214
+ minBitrate = 18 * 1000 * 1000;
215
+ maxBitrate = 80 * 1000 * 1000;
216
+ } else { // high/default
217
+ multiplier = 45;
218
+ minBitrate = 50 * 1000 * 1000;
219
+ maxBitrate = 200 * 1000 * 1000;
220
+ }
221
+
222
+ double base = ((double)MAX(1, width)) * ((double)MAX(1, height)) * (double)multiplier;
223
+ NSInteger bitrate = (NSInteger)base;
224
+ if (bitrate < minBitrate) bitrate = minBitrate;
225
+ if (bitrate > maxBitrate) bitrate = maxBitrate;
226
+
227
+ if (bitrateOut) *bitrateOut = bitrate;
228
+ if (multiplierOut) *multiplierOut = multiplier;
229
+ if (minOut) *minOut = minBitrate;
230
+ if (maxOut) *maxOut = maxBitrate;
231
+ }
232
+
233
+ static dispatch_queue_t ScreenCaptureControlQueue(void);
234
+ static void SCKMarkSchedulingComplete(void);
235
+ static void SCKFailScheduling(void);
236
+ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *content) API_AVAILABLE(macos(12.3));
237
+
42
238
  static void CleanupWriters(void);
239
+
240
+ // SESSION MANAGEMENT FUNCTIONS
241
+ static void InitializeSessionRegistry(void) {
242
+ static dispatch_once_t onceToken;
243
+ dispatch_once(&onceToken, ^{
244
+ g_sessions = [[NSMutableDictionary alloc] init];
245
+ g_sessionsQueue = dispatch_queue_create("com.macrecorder.sessions", DISPATCH_QUEUE_CONCURRENT);
246
+ MRLog(@"๐Ÿ“ฆ Session registry initialized");
247
+ });
248
+ }
249
+
250
+ static NSString *GenerateSessionId(void) {
251
+ return [NSString stringWithFormat:@"rec_%lld", (long long)([[NSDate date] timeIntervalSince1970] * 1000)];
252
+ }
253
+
254
+ static RecordingSession * _Nullable GetSession(NSString *sessionId) {
255
+ if (!sessionId) return nil;
256
+ InitializeSessionRegistry();
257
+
258
+ __block RecordingSession *session = nil;
259
+ dispatch_sync(g_sessionsQueue, ^{
260
+ session = g_sessions[sessionId];
261
+ });
262
+ return session;
263
+ }
264
+
265
+ static NSString *CreateSession(void) {
266
+ InitializeSessionRegistry();
267
+
268
+ NSString *sessionId = GenerateSessionId();
269
+ RecordingSession *session = [[RecordingSession alloc] initWithSessionId:sessionId];
270
+
271
+ dispatch_barrier_async(g_sessionsQueue, ^{
272
+ g_sessions[sessionId] = session;
273
+ MRLog(@"โž• Session created: %@", sessionId);
274
+ });
275
+
276
+ return sessionId;
277
+ }
278
+
279
+ static void RemoveSession(NSString *sessionId) {
280
+ if (!sessionId) return;
281
+ InitializeSessionRegistry();
282
+
283
+ dispatch_barrier_async(g_sessionsQueue, ^{
284
+ RecordingSession *session = g_sessions[sessionId];
285
+ if (session) {
286
+ [session cleanup];
287
+ [g_sessions removeObjectForKey:sessionId];
288
+ MRLog(@"โž– Session removed: %@", sessionId);
289
+ }
290
+ });
291
+ }
292
+
293
+ static NSArray<NSString *> *GetAllSessionIds(void) {
294
+ InitializeSessionRegistry();
295
+
296
+ __block NSArray<NSString *> *sessionIds = nil;
297
+ dispatch_sync(g_sessionsQueue, ^{
298
+ sessionIds = [g_sessions allKeys];
299
+ });
300
+ return sessionIds ?: @[];
301
+ }
302
+
303
+ static NSInteger GetActiveSessionCount(void) {
304
+ InitializeSessionRegistry();
305
+
306
+ __block NSInteger count = 0;
307
+ dispatch_sync(g_sessionsQueue, ^{
308
+ count = g_sessions.count;
309
+ });
310
+ return count;
311
+ }
312
+
43
313
  static AVAssetWriterInputPixelBufferAdaptor * _Nullable CurrentPixelBufferAdaptor(void) {
44
314
  if (!g_pixelBufferAdaptorRef) {
45
315
  return nil;
@@ -103,17 +373,39 @@ static void CleanupWriters(void) {
103
373
  }
104
374
 
105
375
  if (g_audioWriter) {
106
- FinishWriter(g_audioWriter, g_audioInput);
376
+ if (g_systemAudioInput) {
377
+ [g_systemAudioInput markAsFinished];
378
+ }
379
+ if (g_microphoneAudioInput) {
380
+ [g_microphoneAudioInput markAsFinished];
381
+ }
382
+ FinishWriter(g_audioWriter, nil);
107
383
  g_audioWriter = nil;
108
- g_audioInput = nil;
384
+ g_systemAudioInput = nil;
385
+ g_microphoneAudioInput = nil;
109
386
  g_audioWriterStarted = NO;
110
387
  g_audioStartTime = kCMTimeInvalid;
388
+ g_captureMicrophoneEnabled = NO;
389
+ g_captureSystemAudioEnabled = NO;
111
390
  }
112
391
  }
113
392
 
114
393
  @interface PureScreenCaptureDelegate : NSObject <SCStreamDelegate>
115
394
  @end
116
395
 
396
+ // External helpers for mixing/muxing
397
+ extern "C" NSString *currentStandaloneAudioRecordingPath(void);
398
+ extern "C" NSString *lastStandaloneAudioRecordingPath(void);
399
+ extern "C" BOOL MRMixAudioToSingleTrack(NSString *primaryAudioPath,
400
+ NSString *externalMicPath,
401
+ BOOL preferInternalTracks);
402
+ extern "C" BOOL MRMixAudioToSingleTrackWithGains(NSString *primaryAudioPath,
403
+ NSString *externalMicPath,
404
+ BOOL preferInternalTracks,
405
+ float micGain,
406
+ float systemGain);
407
+ extern "C" BOOL MRMuxAudioIntoVideo(NSString *videoPath, NSString *audioPath);
408
+
117
409
  extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
118
410
  if (!g_audioOutputPath) {
119
411
  return nil;
@@ -161,7 +453,8 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
161
453
  @end
162
454
 
163
455
  @interface ScreenCaptureKitRecorder (Private)
164
- + (BOOL)prepareAudioWriterIfNeededWithSampleBuffer:(CMSampleBufferRef)sampleBuffer;
456
+ + (BOOL)prepareAudioWriterIfNeededWithSampleBuffer:(CMSampleBufferRef)sampleBuffer
457
+ isMicrophone:(BOOL)isMicrophone;
165
458
  @end
166
459
 
167
460
  @interface ScreenCaptureVideoOutput : NSObject <SCStreamOutput>
@@ -182,6 +475,7 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
182
475
  }
183
476
 
184
477
  CMTime presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
478
+ MRSyncMarkAudioSample(presentationTime);
185
479
 
186
480
  // Wait for audio to arrive before starting screen video to prevent leading frames.
187
481
  if (MRSyncShouldHoldVideoFrame(presentationTime)) {
@@ -196,8 +490,18 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
196
490
  [g_videoWriter startSessionAtSourceTime:kCMTimeZero];
197
491
  g_videoStartTime = presentationTime;
198
492
  g_videoWriterStarted = YES;
493
+ g_frameCountSinceStart = 0;
199
494
  MRLog(@"๐ŸŽž๏ธ Video writer session started @ %.3f (zero-based timeline)", CMTimeGetSeconds(presentationTime));
200
495
  }
496
+
497
+ // ELECTRON FIX: Track frame count to ensure ScreenCaptureKit is fully running
498
+ if (!g_firstFrameReceived) {
499
+ g_frameCountSinceStart++;
500
+ if (g_frameCountSinceStart >= 10) { // Wait for 10 frames (~150ms at 60fps)
501
+ g_firstFrameReceived = YES;
502
+ MRLog(@"โœ… ScreenCaptureKit fully initialized after %ld frames", (long)g_frameCountSinceStart);
503
+ }
504
+ }
201
505
 
202
506
  if (!g_videoInput.readyForMoreMediaData) {
203
507
  return;
@@ -234,6 +538,18 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
234
538
  relativePresentation = kCMTimeZero;
235
539
  }
236
540
  }
541
+
542
+ double stopLimit = MRSyncGetStopLimitSeconds();
543
+ if (stopLimit > 0) {
544
+ double frameSeconds = CMTimeGetSeconds(relativePresentation);
545
+ double tolerance = g_targetFPS > 0 ? (1.5 / g_targetFPS) : 0.02;
546
+ if (tolerance < 0.02) {
547
+ tolerance = 0.02;
548
+ }
549
+ if (frameSeconds > stopLimit + tolerance) {
550
+ return;
551
+ }
552
+ }
237
553
 
238
554
  AVAssetWriterInputPixelBufferAdaptor *adaptor = adaptorCandidate;
239
555
  BOOL appended = [adaptor appendPixelBuffer:pixelBuffer withPresentationTime:relativePresentation];
@@ -268,24 +584,50 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
268
584
  return;
269
585
  }
270
586
 
271
- if (@available(macOS 13.0, *)) {
272
- if (type != SCStreamOutputTypeAudio) {
273
- return;
587
+ BOOL isMicrophoneSample = NO;
588
+ BOOL isSupportedSample = NO;
589
+ if (@available(macOS 15.0, *)) {
590
+ if (type == SCStreamOutputTypeAudio) {
591
+ isSupportedSample = YES;
592
+ } else if (type == SCStreamOutputTypeMicrophone) {
593
+ isSupportedSample = YES;
594
+ isMicrophoneSample = YES;
274
595
  }
275
- } else {
596
+ } else if (@available(macOS 13.0, *)) {
597
+ if (type == SCStreamOutputTypeAudio) {
598
+ isSupportedSample = YES;
599
+ }
600
+ }
601
+
602
+ if (!isSupportedSample) {
276
603
  return;
277
604
  }
278
605
 
606
+ BOOL routeToMicrophoneTrack = isMicrophoneSample;
607
+ if (!routeToMicrophoneTrack) {
608
+ if (!g_captureSystemAudioEnabled && g_captureMicrophoneEnabled) {
609
+ // Only microphone requested (e.g., macOS < 15), so treat stream as microphone.
610
+ routeToMicrophoneTrack = YES;
611
+ }
612
+ }
613
+
279
614
  if (!CMSampleBufferDataIsReady(sampleBuffer)) {
280
- MRLog(@"โš ๏ธ Audio sample buffer data not ready");
615
+ MRLog(@"โš ๏ธ %@ audio sample buffer not ready",
616
+ routeToMicrophoneTrack ? @"Microphone" : @"System");
281
617
  return;
282
618
  }
283
619
 
284
- if (![ScreenCaptureKitRecorder prepareAudioWriterIfNeededWithSampleBuffer:sampleBuffer]) {
620
+ if (![ScreenCaptureKitRecorder prepareAudioWriterIfNeededWithSampleBuffer:sampleBuffer
621
+ isMicrophone:routeToMicrophoneTrack]) {
285
622
  return;
286
623
  }
287
624
 
288
- if (!g_audioWriter || !g_audioInput) {
625
+ if (!g_audioWriter) {
626
+ return;
627
+ }
628
+
629
+ AVAssetWriterInput *targetInput = routeToMicrophoneTrack ? g_microphoneAudioInput : g_systemAudioInput;
630
+ if (!targetInput) {
289
631
  return;
290
632
  }
291
633
 
@@ -299,13 +641,20 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
299
641
  [g_audioWriter startSessionAtSourceTime:kCMTimeZero];
300
642
  g_audioStartTime = presentationTime;
301
643
  g_audioWriterStarted = YES;
302
- MRLog(@"๐Ÿ”Š Audio writer session started @ %.3f (zero-based timeline)", CMTimeGetSeconds(presentationTime));
644
+ MRLog(@"๐Ÿ”Š Audio writer session started @ %.3f (source=%@)",
645
+ CMTimeGetSeconds(presentationTime),
646
+ routeToMicrophoneTrack ? @"microphone" : @"system");
303
647
  }
304
648
 
305
- if (!g_audioInput.readyForMoreMediaData) {
306
- static int notReadyCount = 0;
307
- if (notReadyCount++ % 100 == 0) {
308
- MRLog(@"โš ๏ธ Audio input not ready for data (count: %d)", notReadyCount);
649
+ static int systemNotReadyCount = 0;
650
+ static int microphoneNotReadyCount = 0;
651
+ int *notReadyCounter = routeToMicrophoneTrack ? &microphoneNotReadyCount : &systemNotReadyCount;
652
+
653
+ if (!targetInput.readyForMoreMediaData) {
654
+ if ((*notReadyCounter)++ % 100 == 0) {
655
+ MRLog(@"โš ๏ธ %@ audio input not ready for data (count: %d)",
656
+ routeToMicrophoneTrack ? @"Microphone" : @"System",
657
+ *notReadyCounter);
309
658
  }
310
659
  return;
311
660
  }
@@ -362,13 +711,34 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
362
711
  }
363
712
  }
364
713
 
365
- BOOL success = [g_audioInput appendSampleBuffer:bufferToAppend];
714
+ double stopLimit = MRSyncGetStopLimitSeconds();
715
+ if (stopLimit > 0) {
716
+ CMTime sampleStart = CMSampleBufferGetPresentationTimeStamp(bufferToAppend);
717
+ double sampleSeconds = CMTimeGetSeconds(sampleStart);
718
+ double sampleDuration = CMTIME_IS_VALID(CMSampleBufferGetDuration(bufferToAppend))
719
+ ? CMTimeGetSeconds(CMSampleBufferGetDuration(bufferToAppend))
720
+ : 0.0;
721
+ double tolerance = 0.02;
722
+ if (sampleSeconds > stopLimit + tolerance ||
723
+ (sampleDuration > 0.0 && (sampleSeconds + sampleDuration) > stopLimit + tolerance)) {
724
+ if (bufferToAppend != sampleBuffer) {
725
+ CFRelease(bufferToAppend);
726
+ }
727
+ return;
728
+ }
729
+ }
730
+
731
+ BOOL success = [targetInput appendSampleBuffer:bufferToAppend];
366
732
  if (!success) {
367
733
  NSLog(@"โš ๏ธ Failed appending audio sample buffer: %@", g_audioWriter.error);
368
734
  } else {
369
- static int appendCount = 0;
370
- if (appendCount++ % 100 == 0) {
371
- MRLog(@"โœ… Audio sample appended successfully (count: %d)", appendCount);
735
+ static int systemAppendCount = 0;
736
+ static int microphoneAppendCount = 0;
737
+ int *appendCount = routeToMicrophoneTrack ? &microphoneAppendCount : &systemAppendCount;
738
+ if ((*appendCount)++ % 100 == 0) {
739
+ MRLog(@"โœ… %@ audio sample appended (count: %d)",
740
+ routeToMicrophoneTrack ? @"Microphone" : @"System",
741
+ *appendCount);
372
742
  }
373
743
  }
374
744
 
@@ -400,23 +770,35 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
400
770
  return NO;
401
771
  }
402
772
 
403
- // QUALITY FIX: ULTRA HIGH quality for screen recording
404
- // ProMotion displays may run at 10Hz (low power) = 10 FPS capture
405
- // Solution: Use VERY HIGH bitrate so each frame is perfect quality
406
- // Use 30x multiplier for ULTRA quality (was 6x - way too low!)
407
- NSInteger bitrate = (NSInteger)(width * height * 30);
408
- bitrate = MAX(bitrate, 30 * 1000 * 1000); // Minimum 30 Mbps for crystal clear screen recording
409
- bitrate = MIN(bitrate, 120 * 1000 * 1000); // Maximum 120 Mbps for ultra quality
773
+ NSInteger bitrate = 0;
774
+ NSInteger bitrateMultiplier = 0;
775
+ NSInteger minBitrate = 0;
776
+ NSInteger maxBitrate = 0;
777
+ NSString *normalizedQuality = SCKNormalizeQualityPreset(g_qualityPreset);
778
+ SCKQualityBitrateForDimensions(normalizedQuality, width, height, &bitrate, &bitrateMultiplier, &minBitrate, &maxBitrate);
779
+
780
+ NSNumber *qualityHint = @0.95;
781
+ if ([normalizedQuality isEqualToString:@"medium"]) {
782
+ qualityHint = @0.9;
783
+ } else if ([normalizedQuality isEqualToString:@"low"]) {
784
+ qualityHint = @0.85;
785
+ }
410
786
 
411
- MRLog(@"๐ŸŽฌ ULTRA QUALITY Screen encoder: %ldx%ld, bitrate=%.2fMbps",
412
- (long)width, (long)height, bitrate / (1000.0 * 1000.0));
787
+ MRLog(@"๐ŸŽฌ Screen encoder (%@): %ldx%ld, multiplier=%ld, bitrate=%.2fMbps (min=%ldMbps max=%ldMbps)",
788
+ normalizedQuality,
789
+ (long)width,
790
+ (long)height,
791
+ (long)bitrateMultiplier,
792
+ bitrate / (1000.0 * 1000.0),
793
+ (long)(minBitrate / (1000 * 1000)),
794
+ (long)(maxBitrate / (1000 * 1000)));
413
795
 
414
796
  NSDictionary *compressionProps = @{
415
797
  AVVideoAverageBitRateKey: @(bitrate),
416
798
  AVVideoMaxKeyFrameIntervalKey: @(MAX(1, g_targetFPS)),
417
799
  AVVideoAllowFrameReorderingKey: @YES,
418
800
  AVVideoExpectedSourceFrameRateKey: @(MAX(1, g_targetFPS)),
419
- AVVideoQualityKey: @(0.95), // 0.0-1.0, higher is better (0.95 = excellent)
801
+ AVVideoQualityKey: qualityHint, // 0.0-1.0, higher is better
420
802
  AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
421
803
  AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCABAC
422
804
  };
@@ -462,61 +844,135 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
462
844
  return YES;
463
845
  }
464
846
 
465
- + (BOOL)prepareAudioWriterIfNeededWithSampleBuffer:(CMSampleBufferRef)sampleBuffer {
466
- if (!g_shouldCaptureAudio || g_audioWriter || !g_audioOutputPath) {
847
+ + (BOOL)prepareAudioWriterIfNeededWithSampleBuffer:(CMSampleBufferRef)sampleBuffer
848
+ isMicrophone:(BOOL)isMicrophone {
849
+ if (!g_shouldCaptureAudio || !g_audioOutputPath) {
467
850
  return g_audioWriter != nil || !g_shouldCaptureAudio;
468
851
  }
469
-
852
+
470
853
  CMFormatDescriptionRef formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer);
471
854
  if (!formatDescription) {
472
855
  NSLog(@"โš ๏ธ Missing audio format description");
473
856
  return NO;
474
857
  }
475
-
476
- const AudioStreamBasicDescription *asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription);
858
+
859
+ const AudioStreamBasicDescription *asbd =
860
+ CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription);
477
861
  if (!asbd) {
478
862
  NSLog(@"โš ๏ธ Unsupported audio format description");
479
863
  return NO;
480
864
  }
481
-
482
- g_configuredSampleRate = (NSInteger)asbd->mSampleRate;
483
- g_configuredChannelCount = asbd->mChannelsPerFrame;
484
-
485
- NSString *originalPath = g_audioOutputPath ?: @"";
486
- NSURL *audioURL = [NSURL fileURLWithPath:originalPath];
487
- [[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
488
-
489
- NSError *writerError = nil;
490
- // CRITICAL FIX: AVAssetWriter does NOT support WebM for audio
491
- // Always use QuickTime Movie format (.mov) for audio files
492
- AVFileType requestedFileType = AVFileTypeQuickTimeMovie;
493
-
494
- // Ensure path has .mov extension for audio
495
- NSString *audioPath = originalPath;
496
- if (![audioPath.pathExtension.lowercaseString isEqualToString:@"mov"]) {
497
- MRLog(@"โš ๏ธ Audio path has wrong extension '%@', changing to .mov", audioPath.pathExtension);
498
- audioPath = [[audioPath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mov"];
499
- g_audioOutputPath = audioPath;
500
- }
501
- audioURL = [NSURL fileURLWithPath:audioPath];
502
- [[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
503
-
504
- @try {
505
- g_audioWriter = [[AVAssetWriter alloc] initWithURL:audioURL fileType:requestedFileType error:&writerError];
506
- } @catch (NSException *exception) {
507
- NSDictionary *info = @{
508
- NSLocalizedDescriptionKey: exception.reason ?: @"Failed to initialize audio writer"
509
- };
510
- writerError = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-201 userInfo:info];
511
- g_audioWriter = nil;
865
+
866
+ if (!g_audioWriter) {
867
+ g_configuredSampleRate = (NSInteger)asbd->mSampleRate;
868
+ g_configuredChannelCount = asbd->mChannelsPerFrame;
869
+
870
+ NSString *originalPath = g_audioOutputPath ?: @"";
871
+ NSURL *audioURL = [NSURL fileURLWithPath:originalPath];
872
+ [[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
873
+
874
+ NSError *writerError = nil;
875
+ AVFileType requestedFileType = AVFileTypeQuickTimeMovie;
876
+
877
+ NSString *audioPath = originalPath;
878
+ if (![audioPath.pathExtension.lowercaseString isEqualToString:@"mov"]) {
879
+ MRLog(@"โš ๏ธ Audio path has wrong extension '%@', changing to .mov", audioPath.pathExtension);
880
+ audioPath = [[audioPath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mov"];
881
+ g_audioOutputPath = audioPath;
882
+ }
883
+ audioURL = [NSURL fileURLWithPath:audioPath];
884
+ [[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
885
+
886
+ @try {
887
+ g_audioWriter = [[AVAssetWriter alloc] initWithURL:audioURL
888
+ fileType:requestedFileType
889
+ error:&writerError];
890
+ } @catch (NSException *exception) {
891
+ NSDictionary *info = @{
892
+ NSLocalizedDescriptionKey: exception.reason ?: @"Failed to initialize audio writer"
893
+ };
894
+ writerError = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-201 userInfo:info];
895
+ g_audioWriter = nil;
896
+ }
897
+
898
+ if (!g_audioWriter || writerError) {
899
+ NSLog(@"โŒ Failed to create audio writer: %@", writerError);
900
+ return NO;
901
+ }
902
+
903
+ // Reset tracking flags whenever we create a new writer
904
+ g_audioWriterStarted = NO;
905
+ g_audioStartTime = kCMTimeInvalid;
906
+
907
+ // CRITICAL FIX: Add BOTH system and microphone inputs NOW (before startWriting)
908
+ // if both are enabled. AVAssetWriter cannot add inputs after startWriting() is called.
909
+ NSLog(@"๐ŸŽ™๏ธ Creating audio writer - system=%d, microphone=%d",
910
+ g_captureSystemAudioEnabled, g_captureMicrophoneEnabled);
512
911
  }
513
-
514
- if (!g_audioWriter || writerError) {
515
- NSLog(@"โŒ Failed to create audio writer: %@", writerError);
516
- return NO;
912
+
913
+ AVAssetWriterInput **targetInput = isMicrophone ? &g_microphoneAudioInput : &g_systemAudioInput;
914
+ if (*targetInput) {
915
+ return YES;
517
916
  }
518
-
519
- NSInteger channelCount = MAX(1, g_configuredChannelCount);
917
+
918
+ // If writer was just created and BOTH sources are enabled, create BOTH inputs now
919
+ if (g_audioWriter && g_captureSystemAudioEnabled && g_captureMicrophoneEnabled) {
920
+ if (!g_systemAudioInput && !g_microphoneAudioInput) {
921
+ NSLog(@"๐ŸŽ™๏ธ Both audio sources enabled - creating both inputs from first sample");
922
+
923
+ NSUInteger channelCount = MAX((NSUInteger)1, (NSUInteger)asbd->mChannelsPerFrame);
924
+ AudioChannelLayout layout = {0};
925
+ size_t layoutSize = 0;
926
+ if (channelCount == 1) {
927
+ layout.mChannelLayoutTag = kAudioChannelLayoutTag_Mono;
928
+ layoutSize = sizeof(AudioChannelLayout);
929
+ } else if (channelCount == 2) {
930
+ layout.mChannelLayoutTag = kAudioChannelLayoutTag_Stereo;
931
+ layoutSize = sizeof(AudioChannelLayout);
932
+ }
933
+
934
+ NSMutableDictionary *audioSettings = [@{
935
+ AVFormatIDKey: @(kAudioFormatMPEG4AAC),
936
+ AVSampleRateKey: @(asbd->mSampleRate),
937
+ AVNumberOfChannelsKey: @(channelCount),
938
+ AVEncoderBitRateKey: @(192000)
939
+ } mutableCopy];
940
+
941
+ if (layoutSize > 0) {
942
+ audioSettings[AVChannelLayoutKey] = [NSData dataWithBytes:&layout length:layoutSize];
943
+ }
944
+
945
+ // ELECTRON FIX: Create microphone input FIRST (stream 0)
946
+ // Electron plays stream 0 by default, so put the more important audio first
947
+ // TODO: Mix both streams into single track for full Electron compatibility
948
+ AVAssetWriterInput *micInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio
949
+ outputSettings:audioSettings];
950
+ micInput.expectsMediaDataInRealTime = YES;
951
+ if ([g_audioWriter canAddInput:micInput]) {
952
+ [g_audioWriter addInput:micInput];
953
+ g_microphoneAudioInput = micInput;
954
+ NSLog(@"โœ… Microphone audio input created (stream 0 - Electron default)");
955
+ } else {
956
+ NSLog(@"โŒ Cannot add microphone audio input");
957
+ }
958
+
959
+ // Create system audio input (stream 1)
960
+ AVAssetWriterInput *systemInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio
961
+ outputSettings:audioSettings];
962
+ systemInput.expectsMediaDataInRealTime = YES;
963
+ if ([g_audioWriter canAddInput:systemInput]) {
964
+ [g_audioWriter addInput:systemInput];
965
+ g_systemAudioInput = systemInput;
966
+ NSLog(@"โœ… System audio input created (stream 1)");
967
+ } else {
968
+ NSLog(@"โŒ Cannot add system audio input");
969
+ }
970
+
971
+ return YES;
972
+ }
973
+ }
974
+
975
+ NSUInteger channelCount = MAX((NSUInteger)1, (NSUInteger)asbd->mChannelsPerFrame);
520
976
  AudioChannelLayout layout = {0};
521
977
  size_t layoutSize = 0;
522
978
  if (channelCount == 1) {
@@ -529,7 +985,7 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
529
985
 
530
986
  NSMutableDictionary *audioSettings = [@{
531
987
  AVFormatIDKey: @(kAudioFormatMPEG4AAC),
532
- AVSampleRateKey: @(g_configuredSampleRate),
988
+ AVSampleRateKey: @(asbd->mSampleRate),
533
989
  AVNumberOfChannelsKey: @(channelCount),
534
990
  AVEncoderBitRateKey: @(192000)
535
991
  } mutableCopy];
@@ -537,22 +993,27 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
537
993
  if (layoutSize > 0) {
538
994
  audioSettings[AVChannelLayoutKey] = [NSData dataWithBytes:&layout length:layoutSize];
539
995
  }
540
-
541
- g_audioInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio outputSettings:audioSettings];
542
- g_audioInput.expectsMediaDataInRealTime = YES;
543
996
 
544
- MRLog(@"๐ŸŽ™๏ธ Audio input created: sampleRate=%ld, channels=%ld, bitrate=192k",
545
- (long)g_configuredSampleRate, (long)channelCount);
997
+ AVAssetWriterInput *newInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio
998
+ outputSettings:audioSettings];
999
+ newInput.expectsMediaDataInRealTime = YES;
546
1000
 
547
- if (![g_audioWriter canAddInput:g_audioInput]) {
548
- NSLog(@"โŒ Audio writer cannot add input");
1001
+ if (![g_audioWriter canAddInput:newInput]) {
1002
+ NSLog(@"โŒ Audio writer cannot add %@ input", isMicrophone ? @"microphone" : @"system");
549
1003
  return NO;
550
1004
  }
551
- [g_audioWriter addInput:g_audioInput];
552
- g_audioWriterStarted = NO;
553
- g_audioStartTime = kCMTimeInvalid;
1005
+ [g_audioWriter addInput:newInput];
1006
+
1007
+ if (isMicrophone) {
1008
+ g_microphoneAudioInput = newInput;
1009
+ MRLog(@"๐ŸŽ™๏ธ Microphone audio input created: sampleRate=%.0f, channels=%ld",
1010
+ asbd->mSampleRate, (long)channelCount);
1011
+ } else {
1012
+ g_systemAudioInput = newInput;
1013
+ MRLog(@"๐Ÿ”ˆ System audio input created: sampleRate=%.0f, channels=%ld",
1014
+ asbd->mSampleRate, (long)channelCount);
1015
+ }
554
1016
 
555
- MRLog(@"โœ… Audio writer prepared successfully (path: %@)", g_audioOutputPath);
556
1017
  return YES;
557
1018
  }
558
1019
 
@@ -564,181 +1025,462 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
564
1025
  }
565
1026
 
566
1027
  + (BOOL)startRecordingWithConfiguration:(NSDictionary *)config delegate:(id)delegate error:(NSError **)error {
567
- @synchronized([ScreenCaptureKitRecorder class]) {
568
- if (g_isRecording || g_isCleaningUp) {
569
- MRLog(@"โš ๏ธ Already recording or cleaning up (recording:%d cleaning:%d)", g_isRecording, g_isCleaningUp);
570
- return NO;
571
- }
1028
+ if (!config) {
1029
+ return NO;
1030
+ }
572
1031
 
573
- // Reset any stale state
574
- g_isCleaningUp = NO;
1032
+ NSDictionary *configCopy = [config copy];
1033
+ dispatch_queue_t controlQueue = ScreenCaptureControlQueue();
1034
+ __block BOOL accepted = NO;
575
1035
 
576
- // DON'T set g_isRecording here - wait for stream to actually start
577
- // This prevents the "recording=1 stream=null" issue
578
- }
1036
+ dispatch_sync(controlQueue, ^{
1037
+ if (g_isRecording || g_isCleaningUp || g_isScheduling) {
1038
+ MRLog(@"โš ๏ธ ScreenCaptureKit busy (recording:%d cleaning:%d scheduling:%d)", g_isRecording, g_isCleaningUp, g_isScheduling);
1039
+ accepted = NO;
1040
+ return;
1041
+ }
1042
+ g_isCleaningUp = NO;
1043
+ g_isScheduling = YES;
1044
+ accepted = YES;
1045
+ });
579
1046
 
580
- NSString *outputPath = config[@"outputPath"];
581
- if (!outputPath || [outputPath length] == 0) {
582
- NSLog(@"โŒ Invalid output path provided");
1047
+ if (!accepted) {
583
1048
  return NO;
584
1049
  }
585
- g_outputPath = outputPath;
586
-
587
- // Extract configuration options
588
- NSNumber *displayId = config[@"displayId"];
589
- NSNumber *windowId = config[@"windowId"];
590
- NSDictionary *captureRect = config[@"captureRect"];
591
- NSNumber *captureCursor = config[@"captureCursor"];
592
- NSNumber *includeMicrophone = config[@"includeMicrophone"];
593
- NSNumber *includeSystemAudio = config[@"includeSystemAudio"];
594
- NSString *microphoneDeviceId = config[@"microphoneDeviceId"];
595
- NSString *audioOutputPath = MRNormalizePath(config[@"audioOutputPath"]);
596
- NSNumber *sessionTimestampNumber = config[@"sessionTimestamp"];
597
-
598
- // Extract requested frame rate
599
- NSNumber *frameRateNumber = config[@"frameRate"];
600
- if (frameRateNumber && [frameRateNumber respondsToSelector:@selector(intValue)]) {
601
- NSInteger fps = [frameRateNumber intValue];
602
- if (fps < 1) fps = 1;
603
- if (fps > 120) fps = 120;
604
- g_targetFPS = fps;
605
- } else {
606
- g_targetFPS = 60;
607
- }
608
-
609
- MRLog(@"๐ŸŽฌ Starting PURE ScreenCaptureKit recording (NO AVFoundation)");
610
- MRLog(@"๐Ÿ”ง Config: cursor=%@ mic=%@ system=%@ display=%@ window=%@ crop=%@",
611
- captureCursor, includeMicrophone, includeSystemAudio, displayId, windowId, captureRect);
612
-
613
- // CRITICAL DEBUG: Log EXACT audio parameter values
614
- MRLog(@"๐Ÿ” AUDIO DEBUG: includeMicrophone type=%@ value=%d", [includeMicrophone class], [includeMicrophone boolValue]);
615
- MRLog(@"๐Ÿ” AUDIO DEBUG: includeSystemAudio type=%@ value=%d", [includeSystemAudio class], [includeSystemAudio boolValue]);
616
1050
 
617
- // ELECTRON FIX: Get shareable content FULLY ASYNCHRONOUSLY
618
- // NO semaphores, NO blocking - pure async to prevent Electron crashes
619
- // CRITICAL: Run on background queue to avoid blocking Electron's main thread
1051
+ // CRITICAL FIX: Use dispatch_get_global_queue instead of main_queue
1052
+ // because Node.js standalone doesn't run macOS main event loop (only Electron does)
1053
+ NSLog(@"๐Ÿš€ Requesting shareable content...");
620
1054
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
621
1055
  [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *contentError) {
622
- @autoreleasepool {
623
- if (contentError) {
1056
+ if (contentError || !content) {
624
1057
  NSLog(@"โŒ Content error: %@", contentError);
625
- // No need to set g_isRecording=NO since it was never set to YES
626
- return; // Early return from completion handler block
627
- }
628
-
629
- MRLog(@"โœ… Got %lu displays, %lu windows for pure recording",
630
- content.displays.count, content.windows.count);
631
-
632
- // CRITICAL DEBUG: List all available displays in ScreenCaptureKit
633
- MRLog(@"๐Ÿ” ScreenCaptureKit available displays:");
634
- for (SCDisplay *display in content.displays) {
635
- MRLog(@" Display ID=%u, Size=%dx%d, Frame=(%.0f,%.0f,%.0fx%.0f)",
636
- display.displayID, (int)display.width, (int)display.height,
637
- display.frame.origin.x, display.frame.origin.y,
638
- display.frame.size.width, display.frame.size.height);
639
- }
640
-
641
- SCContentFilter *filter = nil;
642
- NSInteger recordingWidth = 0;
643
- NSInteger recordingHeight = 0;
644
- SCDisplay *targetDisplay = nil; // Move to shared scope
645
-
646
- // WINDOW RECORDING
647
- if (windowId && [windowId integerValue] != 0) {
648
- SCRunningApplication *targetApp = nil;
649
- SCWindow *targetWindow = nil;
650
-
651
- for (SCWindow *window in content.windows) {
652
- if (window.windowID == [windowId unsignedIntValue]) {
653
- targetWindow = window;
654
- targetApp = window.owningApplication;
655
- break;
656
- }
1058
+ SCKFailScheduling();
1059
+ return;
657
1060
  }
658
-
659
- if (targetWindow && targetApp) {
660
- MRLog(@"๐ŸชŸ Recording window: %@ (%ux%u)",
661
- targetWindow.title, (unsigned)targetWindow.frame.size.width, (unsigned)targetWindow.frame.size.height);
662
- filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
663
- recordingWidth = (NSInteger)targetWindow.frame.size.width;
664
- recordingHeight = (NSInteger)targetWindow.frame.size.height;
1061
+ NSLog(@"โœ… Got shareable content, starting recording setup...");
1062
+ dispatch_async(controlQueue, ^{
1063
+ SCKPerformRecordingSetup(configCopy, content);
1064
+ });
1065
+ }];
1066
+ });
1067
+
1068
+ return YES;
1069
+ }
1070
+
1071
+ + (void)stopRecording {
1072
+ if (!g_isRecording || !g_stream || g_isCleaningUp) {
1073
+ NSLog(@"โš ๏ธ Cannot stop: recording=%d stream=%@ cleaning=%d", g_isRecording, g_stream, g_isCleaningUp);
1074
+ SCKMarkSchedulingComplete();
1075
+ return;
1076
+ }
1077
+
1078
+ MRLog(@"๐Ÿ›‘ Stopping pure ScreenCaptureKit recording");
1079
+
1080
+ // CRITICAL FIX: Set cleanup flag IMMEDIATELY to prevent race conditions
1081
+ // This prevents startRecording from being called while stop is in progress
1082
+ @synchronized([ScreenCaptureKitRecorder class]) {
1083
+ g_isCleaningUp = YES;
1084
+ }
1085
+
1086
+ // Store stream reference to prevent it from being deallocated
1087
+ SCStream *streamToStop = g_stream;
1088
+
1089
+ // ELECTRON FIX: Stop FULLY ASYNCHRONOUSLY - NO blocking, NO semaphores
1090
+ [streamToStop stopCaptureWithCompletionHandler:^(NSError *stopError) {
1091
+ @autoreleasepool {
1092
+ if (stopError) {
1093
+ NSLog(@"โŒ Stop error: %@", stopError);
665
1094
  } else {
666
- NSLog(@"โŒ Window ID %@ not found", windowId);
667
- // No need to set g_isRecording=NO since it was never set to YES
668
- return; // Early return from completion handler block
1095
+ MRLog(@"โœ… Pure stream stopped");
669
1096
  }
670
- }
671
- // DISPLAY RECORDING
672
- else {
673
-
674
- if (displayId && [displayId integerValue] != 0) {
675
- // Find specific display
676
- MRLog(@"๐ŸŽฏ Looking for display ID=%@ in ScreenCaptureKit list", displayId);
677
- for (SCDisplay *display in content.displays) {
678
- MRLog(@" Checking display ID=%u vs requested=%u", display.displayID, [displayId unsignedIntValue]);
679
- if (display.displayID == [displayId unsignedIntValue]) {
680
- targetDisplay = display;
681
- MRLog(@"โœ… FOUND matching display ID=%u", display.displayID);
682
- break;
1097
+
1098
+ // Reset recording state to allow new recordings
1099
+ @synchronized([ScreenCaptureKitRecorder class]) {
1100
+ g_isRecording = NO;
1101
+ g_isCleaningUp = NO; // CRITICAL: Reset cleanup flag when done
1102
+ }
1103
+
1104
+ // Cleanup after stop completes
1105
+ CleanupWriters();
1106
+ [ScreenCaptureKitRecorder cleanupVideoWriter];
1107
+
1108
+ // Post-process: mix (if enabled) then mux audio into video file
1109
+ if (g_shouldCaptureAudio && g_audioOutputPath) {
1110
+ NSString *primaryAudioPath = ScreenCaptureKitCurrentAudioPath();
1111
+ if ([primaryAudioPath isKindOfClass:[NSArray class]]) {
1112
+ id first = [(NSArray *)primaryAudioPath firstObject];
1113
+ if ([first isKindOfClass:[NSString class]]) {
1114
+ primaryAudioPath = (NSString *)first;
1115
+ } else {
1116
+ primaryAudioPath = nil;
683
1117
  }
684
1118
  }
685
-
686
- if (!targetDisplay) {
687
- NSLog(@"โŒ Display ID=%@ NOT FOUND in ScreenCaptureKit - using first display as fallback", displayId);
688
- targetDisplay = content.displays.firstObject;
1119
+ if (primaryAudioPath && [primaryAudioPath length] > 0) {
1120
+ BOOL preferInternal = NO;
1121
+ if (@available(macOS 15.0, *)) {
1122
+ preferInternal = (g_captureSystemAudioEnabled && g_captureMicrophoneEnabled);
1123
+ }
1124
+ NSString *externalMicPath = nil;
1125
+ if (currentStandaloneAudioRecordingPath) {
1126
+ externalMicPath = currentStandaloneAudioRecordingPath();
1127
+ }
1128
+ if (!externalMicPath || [externalMicPath length] == 0) {
1129
+ if (lastStandaloneAudioRecordingPath) {
1130
+ externalMicPath = lastStandaloneAudioRecordingPath();
1131
+ }
1132
+ }
1133
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
1134
+ NSString *audioForMux = primaryAudioPath;
1135
+ if (g_mixAudioEnabled) {
1136
+ BOOL mixed = NO;
1137
+ // Try gain-aware mix first
1138
+ mixed = MRMixAudioToSingleTrackWithGains(primaryAudioPath, externalMicPath, preferInternal, g_mixMicGain, g_mixSystemGain);
1139
+ if (!mixed) {
1140
+ mixed = MRMixAudioToSingleTrack(primaryAudioPath, externalMicPath, preferInternal);
1141
+ }
1142
+ if (mixed) {
1143
+ MRLog(@"๐ŸŽง Post-mix completed: %@", primaryAudioPath);
1144
+ } else {
1145
+ MRLog(@"โ„น๏ธ Post-mix skipped or failed; proceeding to mux");
1146
+ }
1147
+ }
1148
+ if (g_outputPath && [g_outputPath length] > 0) {
1149
+ BOOL muxed = MRMuxAudioIntoVideo(g_outputPath, audioForMux);
1150
+ if (muxed) {
1151
+ MRLog(@"๐Ÿ”— Muxed audio into video: %@", g_outputPath);
1152
+ } else {
1153
+ MRLog(@"โš ๏ธ Failed to mux audio into video %@", g_outputPath);
1154
+ }
1155
+ }
1156
+ });
689
1157
  }
690
- } else {
691
- // Use first display
692
- targetDisplay = content.displays.firstObject;
693
1158
  }
694
-
695
- if (!targetDisplay) {
696
- NSLog(@"โŒ Display not found");
697
- // No need to set g_isRecording=NO since it was never set to YES
698
- return; // Early return from completion handler block
699
- }
700
-
701
- MRLog(@"๐Ÿ–ฅ๏ธ Recording display %u (%dx%d)",
702
- targetDisplay.displayID, (int)targetDisplay.width, (int)targetDisplay.height);
703
- filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:@[]];
704
- recordingWidth = targetDisplay.width;
705
- recordingHeight = targetDisplay.height;
1159
+
1160
+ SCKMarkSchedulingComplete();
706
1161
  }
707
-
708
- // CROP AREA SUPPORT - Adjust dimensions and source rect
709
- if (captureRect && captureRect[@"width"] && captureRect[@"height"]) {
710
- CGFloat cropWidth = [captureRect[@"width"] doubleValue];
711
- CGFloat cropHeight = [captureRect[@"height"] doubleValue];
712
-
1162
+ }];
1163
+ }
1164
+
1165
+ + (BOOL)isRecording {
1166
+ return g_isRecording;
1167
+ }
1168
+
1169
+ + (BOOL)isFullyInitialized {
1170
+ return g_firstFrameReceived;
1171
+ }
1172
+
1173
+ + (NSTimeInterval)getVideoStartTimestamp {
1174
+ if (!CMTIME_IS_VALID(g_videoStartTime)) {
1175
+ return 0;
1176
+ }
1177
+ // Return as milliseconds since epoch - approximate using current time
1178
+ // and relative offset from video start
1179
+ NSDate *now = [NSDate date];
1180
+ NSTimeInterval currentTimestamp = [now timeIntervalSince1970] * 1000;
1181
+
1182
+ // Calculate time elapsed since video start
1183
+ CMTime currentCMTime = CMClockGetTime(CMClockGetHostTimeClock());
1184
+ CMTime elapsedCMTime = CMTimeSubtract(currentCMTime, g_videoStartTime);
1185
+ NSTimeInterval elapsedSeconds = CMTimeGetSeconds(elapsedCMTime);
1186
+
1187
+ // Video start timestamp = current timestamp - elapsed time
1188
+ NSTimeInterval videoStartTimestamp = currentTimestamp - (elapsedSeconds * 1000);
1189
+ return videoStartTimestamp;
1190
+ }
1191
+
1192
+ + (BOOL)isCleaningUp {
1193
+ return g_isCleaningUp;
1194
+ }
1195
+
1196
+ @end
1197
+
1198
+ // Export C function for checking cleanup state
1199
+ BOOL isScreenCaptureKitCleaningUp() API_AVAILABLE(macos(12.3)) {
1200
+ return [ScreenCaptureKitRecorder isCleaningUp];
1201
+ }
1202
+
1203
+ @implementation ScreenCaptureKitRecorder (Methods)
1204
+
1205
+ + (BOOL)setupVideoWriter {
1206
+ // No setup needed - SCRecordingOutput handles everything
1207
+ return YES;
1208
+ }
1209
+
1210
+ + (void)finalizeRecording {
1211
+ @synchronized([ScreenCaptureKitRecorder class]) {
1212
+ MRLog(@"๐ŸŽฌ Finalizing pure ScreenCaptureKit recording");
1213
+
1214
+ // Set cleanup flag now that we're actually cleaning up
1215
+ g_isCleaningUp = YES;
1216
+ g_isRecording = NO;
1217
+
1218
+ [ScreenCaptureKitRecorder cleanupVideoWriter];
1219
+ }
1220
+ }
1221
+
1222
+ + (void)finalizeVideoWriter {
1223
+ // Alias for finalizeRecording to maintain compatibility
1224
+ [ScreenCaptureKitRecorder finalizeRecording];
1225
+ }
1226
+
1227
+ + (void)cleanupVideoWriter {
1228
+ @synchronized([ScreenCaptureKitRecorder class]) {
1229
+ MRLog(@"๐Ÿงน Starting ScreenCaptureKit cleanup");
1230
+
1231
+ // Clean up in proper order to prevent crashes
1232
+ if (g_stream) {
1233
+ g_stream = nil;
1234
+ MRLog(@"โœ… Stream reference cleared");
1235
+ }
1236
+
1237
+ if (g_streamDelegate) {
1238
+ g_streamDelegate = nil;
1239
+ MRLog(@"โœ… Stream delegate reference cleared");
1240
+ }
1241
+
1242
+ g_videoStreamOutput = nil;
1243
+ g_audioStreamOutput = nil;
1244
+ g_videoQueue = nil;
1245
+ g_audioQueue = nil;
1246
+ if (g_pixelBufferAdaptorRef) {
1247
+ CFRelease(g_pixelBufferAdaptorRef);
1248
+ g_pixelBufferAdaptorRef = NULL;
1249
+ }
1250
+ g_audioOutputPath = nil;
1251
+ g_shouldCaptureAudio = NO;
1252
+ g_captureMicrophoneEnabled = NO;
1253
+ g_captureSystemAudioEnabled = NO;
1254
+
1255
+ g_isRecording = NO;
1256
+ g_isCleaningUp = NO; // Reset cleanup flag
1257
+ g_outputPath = nil;
1258
+
1259
+ // ELECTRON FIX: Reset frame tracking
1260
+ g_firstFrameReceived = NO;
1261
+ g_frameCountSinceStart = 0;
1262
+
1263
+ MRLog(@"๐Ÿงน Pure ScreenCaptureKit cleanup complete");
1264
+ }
1265
+ }
1266
+
1267
+ @end
1268
+ static dispatch_queue_t ScreenCaptureControlQueue(void) {
1269
+ static dispatch_queue_t controlQueue = NULL;
1270
+ static dispatch_once_t onceToken;
1271
+ dispatch_once(&onceToken, ^{
1272
+ controlQueue = dispatch_queue_create("com.macrecorder.screencapture.control", DISPATCH_QUEUE_SERIAL);
1273
+ });
1274
+ return controlQueue;
1275
+ }
1276
+
1277
+ static void SCKMarkSchedulingComplete(void) {
1278
+ g_isScheduling = NO;
1279
+ }
1280
+
1281
+ static void SCKFailScheduling(void) {
1282
+ g_isScheduling = NO;
1283
+ g_isRecording = NO;
1284
+ }
1285
+
1286
+ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *content) API_AVAILABLE(macos(12.3)) {
1287
+ @autoreleasepool {
1288
+ if (!config || !content) {
1289
+ SCKFailScheduling();
1290
+ return;
1291
+ }
1292
+
1293
+ // CRITICAL FIX: Reset frame tracking at START of new recording
1294
+ g_firstFrameReceived = NO;
1295
+ g_frameCountSinceStart = 0;
1296
+ NSLog(@"๐Ÿ”„ Frame tracking reset for new recording");
1297
+
1298
+ NSString *outputPath = config[@"outputPath"];
1299
+ if (!outputPath || [outputPath length] == 0) {
1300
+ NSLog(@"โŒ Invalid output path provided");
1301
+ SCKFailScheduling();
1302
+ return;
1303
+ }
1304
+ g_outputPath = outputPath;
1305
+
1306
+ NSNumber *displayId = config[@"displayId"];
1307
+ NSNumber *windowId = config[@"windowId"];
1308
+ NSDictionary *captureRect = config[@"captureRect"];
1309
+ NSNumber *captureCursor = config[@"captureCursor"];
1310
+ NSNumber *includeMicrophone = config[@"includeMicrophone"];
1311
+ NSNumber *includeSystemAudio = config[@"includeSystemAudio"];
1312
+ NSString *microphoneDeviceId = config[@"microphoneDeviceId"];
1313
+ NSString *audioOutputPath = MRNormalizePath(config[@"audioOutputPath"]);
1314
+ NSNumber *sessionTimestampNumber = config[@"sessionTimestamp"];
1315
+ NSNumber *frameRateNumber = config[@"frameRate"];
1316
+ NSNumber *mixAudioNumber = config[@"mixAudio"];
1317
+ g_mixAudioEnabled = mixAudioNumber ? [mixAudioNumber boolValue] : YES;
1318
+ NSNumber *mixMicGainNumber = config[@"mixMicGain"];
1319
+ NSNumber *mixSystemGainNumber = config[@"mixSystemGain"];
1320
+ if (mixMicGainNumber && [mixMicGainNumber respondsToSelector:@selector(floatValue)]) {
1321
+ g_mixMicGain = [mixMicGainNumber floatValue];
1322
+ if (g_mixMicGain < 0.f) g_mixMicGain = 0.f;
1323
+ if (g_mixMicGain > 2.f) g_mixMicGain = 2.f;
1324
+ }
1325
+ if (mixSystemGainNumber && [mixSystemGainNumber respondsToSelector:@selector(floatValue)]) {
1326
+ g_mixSystemGain = [mixSystemGainNumber floatValue];
1327
+ if (g_mixSystemGain < 0.f) g_mixSystemGain = 0.f;
1328
+ if (g_mixSystemGain > 2.f) g_mixSystemGain = 2.f;
1329
+ }
1330
+ g_qualityPreset = SCKNormalizeQualityPreset(config[@"quality"]);
1331
+ MRLog(@"๐ŸŽš๏ธ Requested quality preset: %@", g_qualityPreset);
1332
+ NSNumber *captureCamera = config[@"captureCamera"];
1333
+
1334
+ if (frameRateNumber && [frameRateNumber respondsToSelector:@selector(intValue)]) {
1335
+ NSInteger fps = [frameRateNumber intValue];
1336
+ if (fps < 1) fps = 1;
1337
+ if (fps > 120) fps = 120;
1338
+ g_targetFPS = fps;
1339
+ } else {
1340
+ g_targetFPS = 60;
1341
+ }
1342
+
1343
+ // CRITICAL ELECTRON FIX: Lower FPS to 30 when recording with camera
1344
+ // This prevents resource conflicts and crashes when running both simultaneously
1345
+ BOOL isCameraEnabled = captureCamera && [captureCamera boolValue];
1346
+ if (isCameraEnabled && g_targetFPS > 30) {
1347
+ MRLog(@"๐Ÿ“น Camera recording detected - lowering ScreenCaptureKit FPS from %ld to 30 for stability", (long)g_targetFPS);
1348
+ g_targetFPS = 30;
1349
+ }
1350
+
1351
+ MRLog(@"๐ŸŽฌ Starting PURE ScreenCaptureKit recording (NO AVFoundation)");
1352
+ MRLog(@"๐Ÿ”ง Config: cursor=%@ mic=%@ system=%@ display=%@ window=%@ crop=%@",
1353
+ captureCursor, includeMicrophone, includeSystemAudio, displayId, windowId, captureRect);
1354
+ MRLog(@"๐Ÿ” AUDIO DEBUG: includeMicrophone type=%@ value=%d", [includeMicrophone class], [includeMicrophone boolValue]);
1355
+ MRLog(@"๐Ÿ” AUDIO DEBUG: includeSystemAudio type=%@ value=%d", [includeSystemAudio class], [includeSystemAudio boolValue]);
1356
+ MRLog(@"๐ŸŽš๏ธ Post-mix enabled: %@ (mic=%.2f, sys=%.2f)", g_mixAudioEnabled ? @"YES" : @"NO", g_mixMicGain, g_mixSystemGain);
1357
+
1358
+ MRLog(@"โœ… Got %lu displays, %lu windows for pure recording",
1359
+ content.displays.count, content.windows.count);
1360
+ MRLog(@"๐Ÿ” ScreenCaptureKit available displays:");
1361
+ for (SCDisplay *display in content.displays) {
1362
+ MRLog(@" Display ID=%u, Size=%dx%d, Frame=(%.0f,%.0f,%.0fx%.0f)",
1363
+ display.displayID, (int)display.width, (int)display.height,
1364
+ display.frame.origin.x, display.frame.origin.y,
1365
+ display.frame.size.width, display.frame.size.height);
1366
+ }
1367
+
1368
+ SCContentFilter *filter = nil;
1369
+ NSInteger recordingWidth = 0;
1370
+ NSInteger recordingHeight = 0;
1371
+ SCDisplay *targetDisplay = nil;
1372
+
1373
+ if (windowId && [windowId integerValue] != 0) {
1374
+ SCRunningApplication *targetApp = nil;
1375
+ SCWindow *targetWindow = nil;
1376
+
1377
+ for (SCWindow *window in content.windows) {
1378
+ if (window.windowID == [windowId unsignedIntValue]) {
1379
+ targetWindow = window;
1380
+ targetApp = window.owningApplication;
1381
+ break;
1382
+ }
1383
+ }
1384
+
1385
+ if (targetWindow && targetApp) {
1386
+ MRLog(@"๐ŸชŸ Recording window: %@ (%ux%u)",
1387
+ targetWindow.title, (unsigned)targetWindow.frame.size.width, (unsigned)targetWindow.frame.size.height);
1388
+ filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
1389
+ recordingWidth = (NSInteger)targetWindow.frame.size.width;
1390
+ recordingHeight = (NSInteger)targetWindow.frame.size.height;
1391
+ } else {
1392
+ NSLog(@"โŒ Window ID %@ not found", windowId);
1393
+ SCKFailScheduling();
1394
+ return;
1395
+ }
1396
+ } else {
1397
+ if (displayId) {
1398
+ for (SCDisplay *display in content.displays) {
1399
+ if (display.displayID == [displayId unsignedIntValue]) {
1400
+ targetDisplay = display;
1401
+ break;
1402
+ }
1403
+ }
1404
+
1405
+ if (!targetDisplay && content.displays.count > 0) {
1406
+ NSUInteger count = content.displays.count;
1407
+ NSUInteger idx0 = (NSUInteger)[displayId unsignedIntValue];
1408
+ if (idx0 < count) {
1409
+ targetDisplay = content.displays[idx0];
1410
+ } else if ([displayId unsignedIntegerValue] > 0) {
1411
+ NSUInteger idx1 = [displayId unsignedIntegerValue] - 1;
1412
+ if (idx1 < count) {
1413
+ targetDisplay = content.displays[idx1];
1414
+ }
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ if (!targetDisplay && content.displays.count > 0) {
1420
+ targetDisplay = content.displays.firstObject;
1421
+ }
1422
+
1423
+ if (targetDisplay) {
1424
+ MRLog(@"๐Ÿ–ฅ๏ธ Recording display %u (%dx%d)",
1425
+ targetDisplay.displayID, (int)targetDisplay.width, (int)targetDisplay.height);
1426
+ filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:@[]];
1427
+ recordingWidth = targetDisplay.width;
1428
+ recordingHeight = targetDisplay.height;
1429
+ } else {
1430
+ NSLog(@"โŒ No display available");
1431
+ SCKFailScheduling();
1432
+ return;
1433
+ }
1434
+ }
1435
+
1436
+ if (captureRect && captureRect[@"width"] && captureRect[@"height"]) {
1437
+ CGFloat cropWidth = [captureRect[@"width"] doubleValue];
1438
+ CGFloat cropHeight = [captureRect[@"height"] doubleValue];
713
1439
  if (cropWidth > 0 && cropHeight > 0) {
714
- MRLog(@"๐Ÿ”ฒ Crop area specified: %.0fx%.0f at (%.0f,%.0f)",
715
- cropWidth, cropHeight,
1440
+ MRLog(@"๐Ÿ”ฒ Crop area specified: %.0fx%.0f at (%.0f,%.0f)",
1441
+ cropWidth, cropHeight,
716
1442
  [captureRect[@"x"] doubleValue], [captureRect[@"y"] doubleValue]);
717
1443
  recordingWidth = (NSInteger)cropWidth;
718
1444
  recordingHeight = (NSInteger)cropHeight;
719
1445
  }
720
1446
  }
721
-
722
- // Configure stream with HIGH QUALITY settings
1447
+
1448
+ CGFloat qualityScale = SCKQualityScaleForPreset(g_qualityPreset);
1449
+ if (qualityScale < 0.999 && recordingWidth > 0 && recordingHeight > 0) {
1450
+ NSInteger scaledWidth = MAX(1, (NSInteger)((double)recordingWidth * qualityScale + 0.5));
1451
+ NSInteger scaledHeight = MAX(1, (NSInteger)((double)recordingHeight * qualityScale + 0.5));
1452
+ MRLog(@"๐ŸŽš๏ธ Quality '%@': scaling output to %ldx%ld (%.0f%% of source %ldx%ld)",
1453
+ g_qualityPreset,
1454
+ (long)scaledWidth,
1455
+ (long)scaledHeight,
1456
+ qualityScale * 100.0,
1457
+ (long)recordingWidth,
1458
+ (long)recordingHeight);
1459
+ recordingWidth = scaledWidth;
1460
+ recordingHeight = scaledHeight;
1461
+ } else {
1462
+ MRLog(@"๐ŸŽš๏ธ Quality '%@': using full resolution %ldx%ld",
1463
+ g_qualityPreset,
1464
+ (long)recordingWidth,
1465
+ (long)recordingHeight);
1466
+ }
1467
+
723
1468
  SCStreamConfiguration *streamConfig = [[SCStreamConfiguration alloc] init];
724
1469
  streamConfig.width = recordingWidth;
725
1470
  streamConfig.height = recordingHeight;
726
1471
  streamConfig.minimumFrameInterval = CMTimeMake(1, (int)MAX(1, g_targetFPS));
727
1472
  streamConfig.pixelFormat = kCVPixelFormatType_32BGRA;
728
1473
  streamConfig.scalesToFit = NO;
729
-
730
- // QUALITY FIX: Set high quality encoding parameters
731
1474
  if (@available(macOS 13.0, *)) {
732
- streamConfig.queueDepth = 8; // Larger queue for smoother capture
1475
+ streamConfig.queueDepth = 8;
733
1476
  }
734
1477
 
735
- MRLog(@"๐ŸŽฌ ScreenCaptureKit config: %ldx%ld @ %ldfps", (long)recordingWidth, (long)recordingHeight, (long)g_targetFPS);
736
-
737
1478
  BOOL shouldCaptureMic = includeMicrophone ? [includeMicrophone boolValue] : NO;
738
1479
  BOOL shouldCaptureSystemAudio = includeSystemAudio ? [includeSystemAudio boolValue] : NO;
739
- g_shouldCaptureAudio = shouldCaptureMic || shouldCaptureSystemAudio;
1480
+ g_shouldCaptureAudio = shouldCaptureSystemAudio || shouldCaptureMic;
1481
+ g_captureMicrophoneEnabled = shouldCaptureMic;
1482
+ g_captureSystemAudioEnabled = shouldCaptureSystemAudio;
740
1483
 
741
- // SAFETY: Ensure audioOutputPath is NSString, not NSURL or other type
742
1484
  if (audioOutputPath && ![audioOutputPath isKindOfClass:[NSString class]]) {
743
1485
  MRLog(@"โš ๏ธ audioOutputPath type mismatch: %@, converting...", NSStringFromClass([audioOutputPath class]));
744
1486
  g_audioOutputPath = nil;
@@ -749,84 +1491,82 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
749
1491
  if (g_shouldCaptureAudio && (!g_audioOutputPath || [g_audioOutputPath length] == 0)) {
750
1492
  NSLog(@"โš ๏ธ Audio capture requested but no audio output path supplied โ€“ audio will be disabled");
751
1493
  g_shouldCaptureAudio = NO;
1494
+ g_captureMicrophoneEnabled = NO;
1495
+ g_captureSystemAudioEnabled = NO;
752
1496
  }
753
-
1497
+
754
1498
  if (@available(macos 13.0, *)) {
755
- // capturesAudio enables audio capture (both mic and system audio)
756
1499
  streamConfig.capturesAudio = g_shouldCaptureAudio;
757
1500
  streamConfig.sampleRate = g_configuredSampleRate ?: 48000;
758
1501
  streamConfig.channelCount = g_configuredChannelCount ?: 2;
759
-
760
- // excludesCurrentProcessAudio = YES means ONLY microphone
761
- // excludesCurrentProcessAudio = NO means system audio + mic
762
1502
  streamConfig.excludesCurrentProcessAudio = !shouldCaptureSystemAudio;
763
-
764
- MRLog(@"๐ŸŽค Audio config (macOS 13+): capturesAudio=%d, excludeProcess=%d (mic=%d sys=%d)",
1503
+ NSLog(@"๐ŸŽค Audio config (macOS 13+): capturesAudio=%d, excludeProcess=%d (mic=%d sys=%d)",
765
1504
  g_shouldCaptureAudio, streamConfig.excludesCurrentProcessAudio,
766
1505
  shouldCaptureMic, shouldCaptureSystemAudio);
767
1506
  }
768
1507
 
769
1508
  if (@available(macos 15.0, *)) {
770
- // macOS 15+ has explicit microphone control
771
1509
  streamConfig.captureMicrophone = shouldCaptureMic;
772
- if (microphoneDeviceId && microphoneDeviceId.length > 0) {
773
- streamConfig.microphoneCaptureDeviceID = microphoneDeviceId;
1510
+ NSString *micIdToUse = microphoneDeviceId;
1511
+ if (micIdToUse && micIdToUse.length > 0) {
1512
+ // Validate UniqueID; if invalid, fall back to default to avoid silencing mic
1513
+ AVCaptureDevice *dev = [AVCaptureDevice deviceWithUniqueID:micIdToUse];
1514
+ if (!dev) {
1515
+ NSLog(@"โš ๏ธ Invalid microphone deviceID '%@' โ€“ falling back to default", micIdToUse);
1516
+ micIdToUse = nil;
1517
+ }
774
1518
  }
775
- MRLog(@"๐ŸŽค Microphone (macOS 15+): enabled=%d, deviceID=%@",
776
- shouldCaptureMic, microphoneDeviceId ?: @"default");
1519
+ if (micIdToUse && micIdToUse.length > 0) {
1520
+ streamConfig.microphoneCaptureDeviceID = micIdToUse;
1521
+ }
1522
+ NSLog(@"๐ŸŽค Microphone (macOS 15+): enabled=%d, deviceID=%@",
1523
+ shouldCaptureMic, micIdToUse ?: @"default");
777
1524
  }
778
-
779
- // Apply crop area using sourceRect - CONVERT GLOBAL TO DISPLAY-RELATIVE COORDINATES
780
- if (captureRect && captureRect[@"x"] && captureRect[@"y"] && captureRect[@"width"] && captureRect[@"height"]) {
781
- CGFloat globalX = [captureRect[@"x"] doubleValue];
782
- CGFloat globalY = [captureRect[@"y"] doubleValue];
1525
+
1526
+ if (captureRect && captureRect[@"x"] && captureRect[@"y"] && captureRect[@"width"] && captureRect[@"height"] && targetDisplay) {
1527
+ // CRITICAL FIX: captureRect comes from index.js as ALREADY display-relative coordinates
1528
+ // (see index.js:371-376 where global coords are converted to display-relative)
1529
+ // So we should NOT subtract display origin again - just use them directly!
1530
+ CGFloat displayRelativeX = [captureRect[@"x"] doubleValue];
1531
+ CGFloat displayRelativeY = [captureRect[@"y"] doubleValue];
783
1532
  CGFloat cropWidth = [captureRect[@"width"] doubleValue];
784
1533
  CGFloat cropHeight = [captureRect[@"height"] doubleValue];
785
-
786
- if (cropWidth > 0 && cropHeight > 0 && targetDisplay) {
787
- // Convert global coordinates to display-relative coordinates
788
- CGRect displayBounds = targetDisplay.frame;
789
- CGFloat displayRelativeX = globalX - displayBounds.origin.x;
790
- CGFloat displayRelativeY = globalY - displayBounds.origin.y;
791
-
792
- MRLog(@"๐ŸŒ Global coords: (%.0f,%.0f) on Display ID=%u", globalX, globalY, targetDisplay.displayID);
793
- MRLog(@"๐Ÿ–ฅ๏ธ Display bounds: (%.0f,%.0f,%.0fx%.0f)",
794
- displayBounds.origin.x, displayBounds.origin.y,
1534
+ CGRect displayBounds = targetDisplay.frame;
1535
+
1536
+ MRLog(@"๐Ÿ” CROP DEBUG: Input coords=(%.0f,%.0f) size=(%.0fx%.0f)",
1537
+ displayRelativeX, displayRelativeY, cropWidth, cropHeight);
1538
+ MRLog(@"๐Ÿ” CROP DEBUG: Display bounds origin=(%.0f,%.0f) size=(%.0fx%.0f)",
1539
+ displayBounds.origin.x, displayBounds.origin.y,
1540
+ displayBounds.size.width, displayBounds.size.height);
1541
+
1542
+ if (displayRelativeX >= 0 && displayRelativeY >= 0 &&
1543
+ displayRelativeX + cropWidth <= displayBounds.size.width &&
1544
+ displayRelativeY + cropHeight <= displayBounds.size.height) {
1545
+ CGRect sourceRect = CGRectMake(displayRelativeX, displayRelativeY, cropWidth, cropHeight);
1546
+ streamConfig.sourceRect = sourceRect;
1547
+ MRLog(@"โœ‚๏ธ Crop sourceRect applied: (%.0f,%.0f) %.0fx%.0f (display-relative)",
1548
+ displayRelativeX, displayRelativeY, cropWidth, cropHeight);
1549
+ } else {
1550
+ NSLog(@"โŒ Crop coordinates out of display bounds - skipping crop");
1551
+ MRLog(@" Coords: (%.0f,%.0f) size:(%.0fx%.0f) vs display:(%.0fx%.0f)",
1552
+ displayRelativeX, displayRelativeY, cropWidth, cropHeight,
795
1553
  displayBounds.size.width, displayBounds.size.height);
796
- MRLog(@"๐Ÿ“ Display-relative: (%.0f,%.0f) -> SourceRect", displayRelativeX, displayRelativeY);
797
-
798
- // Validate coordinates are within display bounds
799
- if (displayRelativeX >= 0 && displayRelativeY >= 0 &&
800
- displayRelativeX + cropWidth <= displayBounds.size.width &&
801
- displayRelativeY + cropHeight <= displayBounds.size.height) {
802
-
803
- CGRect sourceRect = CGRectMake(displayRelativeX, displayRelativeY, cropWidth, cropHeight);
804
- streamConfig.sourceRect = sourceRect;
805
- MRLog(@"โœ‚๏ธ Crop sourceRect applied: (%.0f,%.0f) %.0fx%.0f (display-relative)",
806
- displayRelativeX, displayRelativeY, cropWidth, cropHeight);
807
- } else {
808
- NSLog(@"โŒ Crop coordinates out of display bounds - skipping crop");
809
- MRLog(@" Relative: (%.0f,%.0f) size:(%.0fx%.0f) vs display:(%.0fx%.0f)",
810
- displayRelativeX, displayRelativeY, cropWidth, cropHeight,
811
- displayBounds.size.width, displayBounds.size.height);
812
- }
813
1554
  }
814
1555
  }
815
-
816
- // CURSOR SUPPORT
1556
+
817
1557
  BOOL shouldShowCursor = captureCursor ? [captureCursor boolValue] : YES;
818
1558
  streamConfig.showsCursor = shouldShowCursor;
819
-
820
- MRLog(@"๐ŸŽฅ Pure ScreenCapture config: %ldx%ld @ %ldfps, cursor=%d",
1559
+ MRLog(@"๐ŸŽฅ Pure ScreenCapture config: %ldx%ld @ %ldfps, cursor=%d",
821
1560
  recordingWidth, recordingHeight, (long)g_targetFPS, shouldShowCursor);
822
-
1561
+
823
1562
  NSError *writerError = nil;
824
1563
  if (![ScreenCaptureKitRecorder prepareVideoWriterWithWidth:recordingWidth height:recordingHeight error:&writerError]) {
825
1564
  NSLog(@"โŒ Failed to prepare video writer: %@", writerError);
826
- // No need to set g_isRecording=NO since it was never set to YES
827
- return; // Early return from completion handler block
1565
+ SCKFailScheduling();
1566
+ CleanupWriters();
1567
+ return;
828
1568
  }
829
-
1569
+
830
1570
  g_videoQueue = dispatch_queue_create("screen_capture_video_queue", DISPATCH_QUEUE_SERIAL);
831
1571
  g_audioQueue = dispatch_queue_create("screen_capture_audio_queue", DISPATCH_QUEUE_SERIAL);
832
1572
  g_videoStreamOutput = [[ScreenCaptureVideoOutput alloc] init];
@@ -835,20 +1575,16 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
835
1575
  } else {
836
1576
  g_audioStreamOutput = nil;
837
1577
  }
838
-
839
- // Create stream outputs and delegate
1578
+
840
1579
  g_streamDelegate = [[PureScreenCaptureDelegate alloc] init];
841
1580
  g_stream = [[SCStream alloc] initWithFilter:filter configuration:streamConfig delegate:g_streamDelegate];
842
-
843
- // Check if stream was created successfully
844
1581
  if (!g_stream) {
845
1582
  NSLog(@"โŒ Failed to create pure stream");
846
1583
  CleanupWriters();
847
- return; // Early return from completion handler block
1584
+ SCKFailScheduling();
1585
+ return;
848
1586
  }
849
1587
 
850
- MRLog(@"โœ… Stream created successfully");
851
-
852
1588
  NSError *outputError = nil;
853
1589
  BOOL videoOutputAdded = [g_stream addStreamOutput:g_videoStreamOutput type:SCStreamOutputTypeScreen sampleHandlerQueue:g_videoQueue error:&outputError];
854
1590
  if (!videoOutputAdded || outputError) {
@@ -857,24 +1593,81 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
857
1593
  @synchronized([ScreenCaptureKitRecorder class]) {
858
1594
  g_stream = nil;
859
1595
  }
860
- return; // Early return from completion handler block
1596
+ SCKFailScheduling();
1597
+ return;
861
1598
  }
862
1599
 
863
1600
  if (g_shouldCaptureAudio) {
864
1601
  if (@available(macOS 13.0, *)) {
865
1602
  NSError *audioError = nil;
866
- BOOL audioOutputAdded = [g_stream addStreamOutput:g_audioStreamOutput type:SCStreamOutputTypeAudio sampleHandlerQueue:g_audioQueue error:&audioError];
867
- if (!audioOutputAdded || audioError) {
868
- NSLog(@"โŒ Failed to add audio output: %@", audioError);
869
- CleanupWriters();
870
- @synchronized([ScreenCaptureKitRecorder class]) {
871
- g_stream = nil;
1603
+ BOOL anyAudioAdded = NO;
1604
+ if (@available(macOS 15.0, *)) {
1605
+ // On macOS 15+, microphone has its own output type
1606
+ if (g_captureMicrophoneEnabled) {
1607
+ NSLog(@"โž• Adding microphone output stream...");
1608
+ audioError = nil;
1609
+ BOOL micAdded = [g_stream addStreamOutput:g_audioStreamOutput
1610
+ type:SCStreamOutputTypeMicrophone
1611
+ sampleHandlerQueue:g_audioQueue
1612
+ error:&audioError];
1613
+ if (!micAdded || audioError) {
1614
+ NSLog(@"โŒ Failed to add microphone output: %@", audioError);
1615
+ CleanupWriters();
1616
+ @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
1617
+ SCKFailScheduling();
1618
+ return;
1619
+ }
1620
+ anyAudioAdded = YES;
1621
+ NSLog(@"โœ… Microphone output added successfully");
1622
+ }
1623
+ if (g_captureSystemAudioEnabled) {
1624
+ NSLog(@"โž• Adding system audio output stream...");
1625
+ audioError = nil;
1626
+ BOOL sysAdded = [g_stream addStreamOutput:g_audioStreamOutput
1627
+ type:SCStreamOutputTypeAudio
1628
+ sampleHandlerQueue:g_audioQueue
1629
+ error:&audioError];
1630
+ if (!sysAdded || audioError) {
1631
+ NSLog(@"โŒ Failed to add system audio output: %@", audioError);
1632
+ CleanupWriters();
1633
+ @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
1634
+ SCKFailScheduling();
1635
+ return;
1636
+ }
1637
+ anyAudioAdded = YES;
1638
+ NSLog(@"โœ… System audio output added successfully");
872
1639
  }
873
- return; // Early return from completion handler block
1640
+ } else {
1641
+ // macOS 13/14: only SCStreamOutputTypeAudio exists
1642
+ NSLog(@"โž• Adding audio output stream (macOS 13/14)...");
1643
+ audioError = nil;
1644
+ BOOL audAdded = [g_stream addStreamOutput:g_audioStreamOutput
1645
+ type:SCStreamOutputTypeAudio
1646
+ sampleHandlerQueue:g_audioQueue
1647
+ error:&audioError];
1648
+ if (!audAdded || audioError) {
1649
+ NSLog(@"โŒ Failed to add audio output: %@", audioError);
1650
+ CleanupWriters();
1651
+ @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
1652
+ SCKFailScheduling();
1653
+ return;
1654
+ }
1655
+ anyAudioAdded = YES;
1656
+ NSLog(@"โœ… Audio output added successfully");
1657
+ }
1658
+
1659
+ if (!anyAudioAdded) {
1660
+ NSLog(@"โŒ No audio outputs added (unexpected configuration)");
1661
+ CleanupWriters();
1662
+ @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
1663
+ SCKFailScheduling();
1664
+ return;
874
1665
  }
875
1666
  } else {
876
1667
  NSLog(@"โš ๏ธ Audio capture requested but requires macOS 13.0+");
877
1668
  g_shouldCaptureAudio = NO;
1669
+ g_captureMicrophoneEnabled = NO;
1670
+ g_captureSystemAudioEnabled = NO;
878
1671
  }
879
1672
  }
880
1673
 
@@ -883,141 +1676,28 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
883
1676
  MRLog(@"๐Ÿ•’ Session timestamp: %@", sessionTimestampNumber);
884
1677
  }
885
1678
 
886
- // Start capture - can be async
1679
+ NSLog(@"๐Ÿš€ CALLING startCaptureWithCompletionHandler (async)...");
887
1680
  [g_stream startCaptureWithCompletionHandler:^(NSError *startError) {
888
- if (startError) {
889
- NSLog(@"โŒ Failed to start pure capture: %@", startError);
890
- CleanupWriters();
891
- @synchronized([ScreenCaptureKitRecorder class]) {
892
- g_isRecording = NO;
893
- g_stream = nil;
894
- }
895
- } else {
896
- MRLog(@"๐ŸŽ‰ PURE ScreenCaptureKit recording started successfully!");
897
- // NOW set recording flag - stream is actually running
898
- @synchronized([ScreenCaptureKitRecorder class]) {
899
- g_isRecording = YES;
1681
+ dispatch_async(ScreenCaptureControlQueue(), ^{
1682
+ if (startError) {
1683
+ NSLog(@"โŒ Failed to start pure capture: %@", startError);
1684
+ NSLog(@"โŒ Error domain: %@, code: %ld", startError.domain, (long)startError.code);
1685
+ NSLog(@"โŒ Error userInfo: %@", startError.userInfo);
1686
+ CleanupWriters();
1687
+ @synchronized([ScreenCaptureKitRecorder class]) {
1688
+ g_isRecording = NO;
1689
+ g_stream = nil;
1690
+ }
1691
+ SCKFailScheduling();
1692
+ } else {
1693
+ NSLog(@"๐ŸŽ‰ PURE ScreenCaptureKit recording started successfully!");
1694
+ NSLog(@"๐ŸŽค Audio capture enabled: %d (mic=%d, system=%d)", g_shouldCaptureAudio, g_captureMicrophoneEnabled, g_captureSystemAudioEnabled);
1695
+ @synchronized([ScreenCaptureKitRecorder class]) {
1696
+ g_isRecording = YES;
1697
+ }
1698
+ SCKMarkSchedulingComplete();
900
1699
  }
901
- }
902
- }]; // End of startCaptureWithCompletionHandler
903
- } // End of autoreleasepool
904
- }]; // End of getShareableContentWithCompletionHandler
905
- }); // End of dispatch_async
906
-
907
- // Return immediately - async completion will handle success/failure
908
- return YES;
909
- }
910
-
911
- + (void)stopRecording {
912
- if (!g_isRecording || !g_stream || g_isCleaningUp) {
913
- NSLog(@"โš ๏ธ Cannot stop: recording=%d stream=%@ cleaning=%d", g_isRecording, g_stream, g_isCleaningUp);
914
- return;
915
- }
916
-
917
- MRLog(@"๐Ÿ›‘ Stopping pure ScreenCaptureKit recording");
918
-
919
- // CRITICAL FIX: Set cleanup flag IMMEDIATELY to prevent race conditions
920
- // This prevents startRecording from being called while stop is in progress
921
- @synchronized([ScreenCaptureKitRecorder class]) {
922
- g_isCleaningUp = YES;
923
- }
924
-
925
- // Store stream reference to prevent it from being deallocated
926
- SCStream *streamToStop = g_stream;
927
-
928
- // ELECTRON FIX: Stop FULLY ASYNCHRONOUSLY - NO blocking, NO semaphores
929
- [streamToStop stopCaptureWithCompletionHandler:^(NSError *stopError) {
930
- @autoreleasepool {
931
- if (stopError) {
932
- NSLog(@"โŒ Stop error: %@", stopError);
933
- } else {
934
- MRLog(@"โœ… Pure stream stopped");
935
- }
936
-
937
- // Reset recording state to allow new recordings
938
- @synchronized([ScreenCaptureKitRecorder class]) {
939
- g_isRecording = NO;
940
- g_isCleaningUp = NO; // CRITICAL: Reset cleanup flag when done
941
- }
942
-
943
- // Cleanup after stop completes
944
- CleanupWriters();
945
- [ScreenCaptureKitRecorder cleanupVideoWriter];
946
- }
947
- }];
948
- }
949
-
950
- + (BOOL)isRecording {
951
- return g_isRecording;
952
- }
953
-
954
- + (BOOL)isCleaningUp {
955
- return g_isCleaningUp;
956
- }
957
-
958
- @end
959
-
960
- // Export C function for checking cleanup state
961
- BOOL isScreenCaptureKitCleaningUp() API_AVAILABLE(macos(12.3)) {
962
- return [ScreenCaptureKitRecorder isCleaningUp];
963
- }
964
-
965
- @implementation ScreenCaptureKitRecorder (Methods)
966
-
967
- + (BOOL)setupVideoWriter {
968
- // No setup needed - SCRecordingOutput handles everything
969
- return YES;
970
- }
971
-
972
- + (void)finalizeRecording {
973
- @synchronized([ScreenCaptureKitRecorder class]) {
974
- MRLog(@"๐ŸŽฌ Finalizing pure ScreenCaptureKit recording");
975
-
976
- // Set cleanup flag now that we're actually cleaning up
977
- g_isCleaningUp = YES;
978
- g_isRecording = NO;
979
-
980
- [ScreenCaptureKitRecorder cleanupVideoWriter];
1700
+ });
1701
+ }];
981
1702
  }
982
1703
  }
983
-
984
- + (void)finalizeVideoWriter {
985
- // Alias for finalizeRecording to maintain compatibility
986
- [ScreenCaptureKitRecorder finalizeRecording];
987
- }
988
-
989
- + (void)cleanupVideoWriter {
990
- @synchronized([ScreenCaptureKitRecorder class]) {
991
- MRLog(@"๐Ÿงน Starting ScreenCaptureKit cleanup");
992
-
993
- // Clean up in proper order to prevent crashes
994
- if (g_stream) {
995
- g_stream = nil;
996
- MRLog(@"โœ… Stream reference cleared");
997
- }
998
-
999
- if (g_streamDelegate) {
1000
- g_streamDelegate = nil;
1001
- MRLog(@"โœ… Stream delegate reference cleared");
1002
- }
1003
-
1004
- g_videoStreamOutput = nil;
1005
- g_audioStreamOutput = nil;
1006
- g_videoQueue = nil;
1007
- g_audioQueue = nil;
1008
- if (g_pixelBufferAdaptorRef) {
1009
- CFRelease(g_pixelBufferAdaptorRef);
1010
- g_pixelBufferAdaptorRef = NULL;
1011
- }
1012
- g_audioOutputPath = nil;
1013
- g_shouldCaptureAudio = NO;
1014
-
1015
- g_isRecording = NO;
1016
- g_isCleaningUp = NO; // Reset cleanup flag
1017
- g_outputPath = nil;
1018
-
1019
- MRLog(@"๐Ÿงน Pure ScreenCaptureKit cleanup complete");
1020
- }
1021
- }
1022
-
1023
- @end