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.
@@ -31,6 +31,8 @@ extern "C" {
31
31
  bool stopCameraRecording();
32
32
  bool isCameraRecording();
33
33
  NSString *currentCameraRecordingPath();
34
+ bool waitForCameraRecordingStart(double timeoutSeconds);
35
+ double currentCameraRecordingStartTime(void);
34
36
  NSString *currentStandaloneAudioRecordingPath();
35
37
 
36
38
  NSArray<NSDictionary *> *listAudioCaptureDevices();
@@ -62,6 +64,42 @@ extern "C" void showOverlays();
62
64
  static MacRecorderDelegate *g_delegate = nil;
63
65
  static bool g_isRecording = false;
64
66
  static bool g_usingStandaloneAudio = false;
67
+ static CFTimeInterval g_recordingStartTime = 0;
68
+ static bool g_hasRecordingStartTime = false;
69
+ static double g_activeStopLimitSeconds = -1.0;
70
+
71
+ static void MRMarkRecordingStartTimestamp(void) {
72
+ g_recordingStartTime = CFAbsoluteTimeGetCurrent();
73
+ g_hasRecordingStartTime = true;
74
+ }
75
+
76
+ static double MRComputeElapsedRecordingSeconds(void) {
77
+ if (!g_hasRecordingStartTime) {
78
+ return -1.0;
79
+ }
80
+ CFTimeInterval elapsed = CFAbsoluteTimeGetCurrent() - g_recordingStartTime;
81
+ if (elapsed <= 0.0) {
82
+ return -1.0;
83
+ }
84
+ return (double)elapsed;
85
+ }
86
+
87
+ static void MRResetRecordingStartTimestamp(void) {
88
+ g_hasRecordingStartTime = false;
89
+ g_recordingStartTime = 0;
90
+ }
91
+
92
+ static void MRStoreActiveStopLimit(double seconds) {
93
+ g_activeStopLimitSeconds = seconds;
94
+ }
95
+
96
+ extern "C" double MRActiveStopLimitSeconds(void) {
97
+ return g_activeStopLimitSeconds;
98
+ }
99
+
100
+ extern "C" double MRScreenRecordingStartTimestampSeconds(void) {
101
+ return g_hasRecordingStartTime ? g_recordingStartTime : 0.0;
102
+ }
65
103
 
66
104
  static bool startCameraIfRequested(bool captureCamera,
67
105
  NSString **cameraOutputPathRef,
@@ -111,6 +149,32 @@ static bool startCameraIfRequested(bool captureCamera,
111
149
  return true;
112
150
  }
113
151
 
152
+ static bool startCameraWithConfirmation(bool captureCamera,
153
+ NSString **cameraOutputPathRef,
154
+ NSString *cameraDeviceId,
155
+ const std::string &screenOutputPath,
156
+ int64_t sessionTimestampMs) {
157
+ if (!captureCamera) {
158
+ return true;
159
+ }
160
+ if (!startCameraIfRequested(true, cameraOutputPathRef, cameraDeviceId, screenOutputPath, sessionTimestampMs)) {
161
+ return false;
162
+ }
163
+ double cameraWaitTimeout = 8.0; // allow slower devices (e.g., Continuity) to spin up
164
+ if (!waitForCameraRecordingStart(cameraWaitTimeout)) {
165
+ double cameraStartTs = currentCameraRecordingStartTime();
166
+ if (cameraStartTs > 0 || isCameraRecording()) {
167
+ MRLog(@"⚠️ Camera did not confirm start within %.1fs but appears to be running; continuing", cameraWaitTimeout);
168
+ return true;
169
+ }
170
+ MRLog(@"❌ Camera did not signal recording start within %.1fs", cameraWaitTimeout);
171
+ stopCameraRecording();
172
+ return false;
173
+ }
174
+ MRLog(@"✅ Camera recording confirmed");
175
+ return true;
176
+ }
177
+
114
178
  static bool startAudioIfRequested(bool captureAudio,
115
179
  NSString *audioOutputPath,
116
180
  NSString *preferredDeviceId) {
@@ -160,6 +224,8 @@ void cleanupRecording() {
160
224
 
161
225
  g_isRecording = false;
162
226
  MRSyncConfigure(NO);
227
+ MRResetRecordingStartTimestamp();
228
+ MRStoreActiveStopLimit(-1.0);
163
229
  }
164
230
 
165
231
  // NAPI Function: Start Recording
@@ -175,6 +241,12 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
175
241
  // This fixes the issue where macOS 14/13 users get "recording already in progress"
176
242
  MRLog(@"🧹 Cleaning up any previous recording state...");
177
243
  cleanupRecording();
244
+ MRStoreActiveStopLimit(-1.0);
245
+
246
+ // CRITICAL FIX: Reset sync stop limit to prevent consecutive recording issues
247
+ // The sync stop limit persists from previous recording and causes camera to stop early
248
+ MRSyncSetStopLimitSeconds(-1.0);
249
+ MRLog(@"✅ Stop limit reset to unlimited for new recording");
178
250
 
179
251
  if (g_isRecording) {
180
252
  MRLog(@"⚠️ Still recording after cleanup - forcing stop");
@@ -208,6 +280,11 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
208
280
  int64_t sessionTimestamp = 0;
209
281
  NSString *audioOutputPath = nil;
210
282
  double frameRate = 60.0;
283
+ NSString *qualityPreset = @"high";
284
+ bool mixAudio = true; // Default: mix mic+system into single track when possible
285
+ double mixMicGain = 0.8; // default mic priority
286
+ double mixSystemGain = 0.4; // default system lower
287
+ bool preferScreenCaptureKitOption = false; // Allow opting into ScreenCaptureKit
211
288
 
212
289
  if (info.Length() > 1 && info[1].IsObject()) {
213
290
  Napi::Object options = info[1].As<Napi::Object>();
@@ -286,6 +363,51 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
286
363
  frameRate = fps;
287
364
  }
288
365
  }
366
+
367
+ // Quality preset (low, medium, high)
368
+ if (options.Has("quality") && options.Get("quality").IsString()) {
369
+ std::string qualityStd = options.Get("quality").As<Napi::String>().Utf8Value();
370
+ NSString *qualityStr = [NSString stringWithUTF8String:qualityStd.c_str()];
371
+ if (qualityStr && [qualityStr length] > 0) {
372
+ NSString *normalized = [[qualityStr stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString];
373
+ if ([normalized length] > 0) {
374
+ qualityPreset = normalized;
375
+ }
376
+ }
377
+ }
378
+
379
+ // Optional: allow caller to prefer ScreenCaptureKit when available (macOS 15+)
380
+ if (options.Has("preferScreenCaptureKit")) {
381
+ preferScreenCaptureKitOption = options.Get("preferScreenCaptureKit").As<Napi::Boolean>();
382
+ }
383
+
384
+ // Post-process audio mixing toggle (default true)
385
+ if (options.Has("mixAudio")) {
386
+ mixAudio = options.Get("mixAudio").As<Napi::Boolean>();
387
+ }
388
+
389
+ // Equal mix or explicit gains
390
+ if (options.Has("equalMix") && options.Get("equalMix").IsBoolean()) {
391
+ bool eq = options.Get("equalMix").As<Napi::Boolean>();
392
+ if (eq) {
393
+ mixMicGain = 0.5;
394
+ mixSystemGain = 0.5;
395
+ }
396
+ }
397
+ if (options.Has("mixGains") && options.Get("mixGains").IsObject()) {
398
+ Napi::Object gains = options.Get("mixGains").As<Napi::Object>();
399
+ if (gains.Has("mic") && gains.Get("mic").IsNumber()) {
400
+ mixMicGain = gains.Get("mic").As<Napi::Number>().DoubleValue();
401
+ }
402
+ if (gains.Has("system") && gains.Get("system").IsNumber()) {
403
+ mixSystemGain = gains.Get("system").As<Napi::Number>().DoubleValue();
404
+ }
405
+ // Clamp
406
+ if (mixMicGain < 0.0) mixMicGain = 0.0;
407
+ if (mixMicGain > 2.0) mixMicGain = 2.0;
408
+ if (mixSystemGain < 0.0) mixSystemGain = 0.0;
409
+ if (mixSystemGain > 2.0) mixSystemGain = 2.0;
410
+ }
289
411
 
290
412
  // Display ID (accepts either real CGDirectDisplayID or index [0-based or 1-based])
291
413
  if (options.Has("displayId") && !options.Get("displayId").IsNull()) {
@@ -346,7 +468,13 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
346
468
 
347
469
  bool captureMicrophone = includeMicrophone;
348
470
  bool captureSystemAudio = includeSystemAudio;
349
- bool captureAnyAudio = captureMicrophone || captureSystemAudio;
471
+ bool screenCaptureSupportsMic = false;
472
+ if (@available(macOS 15.0, *)) {
473
+ screenCaptureSupportsMic = true;
474
+ }
475
+ bool captureStandaloneMic = captureMicrophone && !screenCaptureSupportsMic;
476
+ bool captureScreenAudio = captureSystemAudio || (screenCaptureSupportsMic && captureMicrophone);
477
+ bool captureAnyAudio = captureScreenAudio || captureStandaloneMic;
350
478
  MRSyncConfigure(captureAnyAudio);
351
479
  NSString *preferredAudioDeviceId = nil;
352
480
  if (captureSystemAudio && systemAudioDeviceId && [systemAudioDeviceId length] > 0) {
@@ -354,7 +482,47 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
354
482
  } else if (captureMicrophone && audioDeviceId && [audioDeviceId length] > 0) {
355
483
  preferredAudioDeviceId = audioDeviceId;
356
484
  }
357
-
485
+
486
+ // Auto-generate audio output path if audio is requested but no path provided
487
+ if (captureAnyAudio && (!audioOutputPath || [audioOutputPath length] == 0)) {
488
+ NSString *screenPath = [NSString stringWithUTF8String:outputPath.c_str()];
489
+ NSString *directory = nil;
490
+ if (screenPath && [screenPath length] > 0) {
491
+ directory = [screenPath stringByDeletingLastPathComponent];
492
+ }
493
+ if (!directory || [directory length] == 0) {
494
+ directory = [[NSFileManager defaultManager] currentDirectoryPath];
495
+ }
496
+ int64_t ts = sessionTimestamp != 0 ? sessionTimestamp : (int64_t)([[NSDate date] timeIntervalSince1970] * 1000.0);
497
+ NSString *fileName = [NSString stringWithFormat:@"temp_audio_%lld.mov", ts];
498
+ audioOutputPath = [directory stringByAppendingPathComponent:fileName];
499
+ MRLog(@"📁 Auto-generated audio path: %@", audioOutputPath);
500
+ }
501
+
502
+ NSString *standaloneMicOutputPath = nil;
503
+ if (captureStandaloneMic) {
504
+ if (captureScreenAudio) {
505
+ // Avoid clashing writers: use a different file for standalone mic when SCK also writes audio
506
+ NSString *screenPath = [NSString stringWithUTF8String:outputPath.c_str()];
507
+ NSString *directory = screenPath && [screenPath length] > 0
508
+ ? [screenPath stringByDeletingLastPathComponent]
509
+ : [[NSFileManager defaultManager] currentDirectoryPath];
510
+ int64_t tsMic = sessionTimestamp != 0 ? sessionTimestamp : (int64_t)([[NSDate date] timeIntervalSince1970] * 1000.0);
511
+ NSString *micFileName = [NSString stringWithFormat:@"temp_audio_mic_%lld.mov", tsMic];
512
+ standaloneMicOutputPath = [directory stringByAppendingPathComponent:micFileName];
513
+ } else {
514
+ standaloneMicOutputPath = audioOutputPath;
515
+ }
516
+
517
+ // Always use microphone device ID for standalone mic capture
518
+ if (!startAudioIfRequested(true, standaloneMicOutputPath, audioDeviceId)) {
519
+ MRLog(@"❌ Standalone microphone recording failed to start");
520
+ return Napi::Boolean::New(env, false);
521
+ }
522
+ g_usingStandaloneAudio = true;
523
+ MRLog(@"🎙️ Standalone microphone recording started (audio-only)");
524
+ }
525
+
358
526
  @try {
359
527
  // Smart Recording Selection: ScreenCaptureKit vs Alternative
360
528
  MRLog(@"🎯 Smart Recording Engine Selection");
@@ -379,26 +547,30 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
379
547
 
380
548
  if (isElectron) {
381
549
  MRLog(@"⚡ Electron environment detected");
382
- MRLog(@"🔧 CRITICAL FIX: Forcing AVFoundation for Electron stability");
383
- MRLog(@" Reason: ScreenCaptureKit has thread safety issues in Electron (SIGTRAP crashes)");
384
550
  }
385
551
 
386
- // CRITICAL FIX: Always use AVFoundation for stability
387
- // ScreenCaptureKit has file writing issues in Node.js environment
388
- // AVFoundation works reliably in both Node.js and Electron
389
- BOOL forceAVFoundation = YES;
552
+ // Allow preferring ScreenCaptureKit via env or option; allow forcing AVFoundation
553
+ BOOL preferSCKEnv = (getenv("PREFER_SCREENCAPTUREKIT") != NULL) ||
554
+ (getenv("USE_SCREENCAPTUREKIT") != NULL) ||
555
+ (getenv("FORCE_SCREENCAPTUREKIT") != NULL) ||
556
+ preferScreenCaptureKitOption;
557
+ BOOL forceAVFoundationEnv = (getenv("FORCE_AVFOUNDATION") != NULL);
390
558
 
391
- MRLog(@"🔧 FRAMEWORK SELECTION: Using AVFoundation for stability");
559
+ // POLICY (per request): On macOS 14+ always try ScreenCaptureKit first (Electron included),
560
+ // then gracefully fall back to AVFoundation if unavailable/fails.
561
+ BOOL tryScreenCaptureKit = (isM14Plus || isM15Plus) && !forceAVFoundationEnv;
562
+
563
+ MRLog(@"🔧 FRAMEWORK SELECTION INPUTS:");
392
564
  MRLog(@" Environment: %@", isElectron ? @"Electron" : @"Node.js");
393
565
  MRLog(@" macOS: %ld.%ld.%ld", (long)osVersion.majorVersion, (long)osVersion.minorVersion, (long)osVersion.patchVersion);
566
+ MRLog(@" preferSCK(env|option): %@", preferSCKEnv ? @"YES" : @"NO");
567
+ MRLog(@" forceAV(env): %@", forceAVFoundationEnv ? @"YES" : @"NO");
394
568
 
395
- // Electron-first priority: ALWAYS use AVFoundation in Electron for stability
396
- // ScreenCaptureKit has severe thread safety issues in Electron causing SIGTRAP crashes
397
- if (isM15Plus && !forceAVFoundation) {
569
+ if (tryScreenCaptureKit) {
398
570
  if (isElectron) {
399
- MRLog(@"⚡ ELECTRON PRIORITY: macOS 15+ Electron → ScreenCaptureKit with full support");
571
+ MRLog(@"⚡ ELECTRON: macOS 14+ → trying ScreenCaptureKit first");
400
572
  } else {
401
- MRLog(@"✅ macOS 15+ Node.js → ScreenCaptureKit available with full compatibility");
573
+ MRLog(@"✅ macOS 14+ Node.js → using ScreenCaptureKit");
402
574
  }
403
575
 
404
576
  // Try ScreenCaptureKit with extensive safety measures
@@ -413,10 +585,12 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
413
585
  sckConfig[@"displayId"] = @(displayID);
414
586
  sckConfig[@"windowId"] = @(windowID);
415
587
  sckConfig[@"captureCursor"] = @(captureCursor);
416
- sckConfig[@"includeSystemAudio"] = @(includeSystemAudio);
417
- sckConfig[@"includeMicrophone"] = @(includeMicrophone);
588
+ sckConfig[@"includeSystemAudio"] = @(captureScreenAudio);
589
+ sckConfig[@"includeMicrophone"] = @((screenCaptureSupportsMic && captureMicrophone) ? YES : NO);
418
590
  sckConfig[@"audioDeviceId"] = audioDeviceId;
419
591
  sckConfig[@"outputPath"] = [NSString stringWithUTF8String:outputPath.c_str()];
592
+ // Let ScreenCaptureKit know camera is active so it can adjust FPS/resource usage
593
+ sckConfig[@"captureCamera"] = @(captureCamera);
420
594
  if (audioOutputPath) {
421
595
  sckConfig[@"audioOutputPath"] = audioOutputPath;
422
596
  }
@@ -426,8 +600,12 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
426
600
  if (sessionTimestamp != 0) {
427
601
  sckConfig[@"sessionTimestamp"] = @(sessionTimestamp);
428
602
  }
429
- // Pass requested frame rate
603
+ // Pass requested frame rate and audio mixing preference
430
604
  sckConfig[@"frameRate"] = @(frameRate);
605
+ sckConfig[@"mixAudio"] = @(mixAudio);
606
+ sckConfig[@"mixMicGain"] = @((double)mixMicGain);
607
+ sckConfig[@"mixSystemGain"] = @((double)mixSystemGain);
608
+ sckConfig[@"quality"] = qualityPreset ?: @"high";
431
609
 
432
610
  if (!CGRectIsNull(captureRect)) {
433
611
  sckConfig[@"captureRect"] = @{
@@ -441,6 +619,16 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
441
619
  // Use ScreenCaptureKit with window exclusion and timeout protection
442
620
  NSError *sckError = nil;
443
621
 
622
+ // CRITICAL SYNC FIX: Start camera BEFORE ScreenCaptureKit
623
+ // This allows camera warmup to happen in parallel with async ScreenCaptureKit init
624
+ if (captureCamera) {
625
+ MRLog(@"🎥 Starting camera recording BEFORE ScreenCaptureKit (parallel warmup)");
626
+ if (!startCameraWithConfirmation(true, &cameraOutputPath, cameraDeviceId, outputPath, sessionTimestamp)) {
627
+ MRLog(@"❌ Camera failed to start - aborting recording");
628
+ return Napi::Boolean::New(env, false);
629
+ }
630
+ }
631
+
444
632
  // Set timeout for ScreenCaptureKit initialization
445
633
  // Attempt to start ScreenCaptureKit with safety wrapper
446
634
  @try {
@@ -453,18 +641,9 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
453
641
  MRLog(@"🎬 RECORDING METHOD: ScreenCaptureKit");
454
642
  MRLog(@"✅ SYNC: ScreenCaptureKit recording started successfully");
455
643
 
456
- if (captureCamera) {
457
- MRLog(@"🎯 SYNC: Starting camera recording after screen start");
458
- bool cameraStarted = startCameraIfRequested(captureCamera, &cameraOutputPath, cameraDeviceId, outputPath, sessionTimestamp);
459
- if (!cameraStarted) {
460
- MRLog(@"❌ Camera start failed - stopping ScreenCaptureKit recording");
461
- [ScreenCaptureKitRecorder stopRecording];
462
- return Napi::Boolean::New(env, false);
463
- }
464
- MRLog(@"✅ SYNC: Camera recording started");
465
- }
466
-
467
644
  g_isRecording = true;
645
+ MRMarkRecordingStartTimestamp();
646
+
468
647
  return Napi::Boolean::New(env, true);
469
648
  } else {
470
649
  NSLog(@"❌ ScreenCaptureKit failed to start");
@@ -473,11 +652,11 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
473
652
  } @catch (NSException *sckException) {
474
653
  NSLog(@"❌ Exception during ScreenCaptureKit startup: %@", sckException.reason);
475
654
 
476
- // Cleanup camera on exception
477
- if (isCameraRecording()) {
478
- stopCameraRecording();
479
- }
655
+ // Cleanup camera on exception
656
+ if (isCameraRecording()) {
657
+ stopCameraRecording();
480
658
  }
659
+ }
481
660
  NSLog(@"❌ ScreenCaptureKit failed or unsafe - will fallback to AVFoundation");
482
661
 
483
662
  } else {
@@ -501,9 +680,7 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
501
680
  MRLog(@"⚡ ELECTRON PRIORITY: macOS 13 Electron → AVFoundation with limited features");
502
681
  }
503
682
  } else {
504
- if (isM15Plus) {
505
- MRLog(@"🎯 macOS 15+ Node.js with FORCE_AVFOUNDATION → using AVFoundation");
506
- } else if (isM14Plus) {
683
+ if (isM14Plus) {
507
684
  MRLog(@"🎯 macOS 14 Node.js → using AVFoundation (primary method)");
508
685
  } else if (isM13Plus) {
509
686
  MRLog(@"🎯 macOS 13 Node.js → using AVFoundation (limited features)");
@@ -545,20 +722,20 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
545
722
  MRLog(@"✅ SYNC: Screen recording started successfully");
546
723
 
547
724
  if (captureCamera) {
548
- MRLog(@"🎯 SYNC: Starting camera recording after screen start");
549
- bool cameraStarted = startCameraIfRequested(captureCamera, &cameraOutputPath, cameraDeviceId, outputPath, sessionTimestamp);
550
- if (!cameraStarted) {
551
- MRLog(@"❌ Camera start failed - stopping screen recording");
725
+ MRLog(@"🎥 Starting camera recording for AVFoundation fallback");
726
+ if (!startCameraWithConfirmation(true, &cameraOutputPath, cameraDeviceId, outputPath, sessionTimestamp)) {
727
+ MRLog(@"❌ Camera failed to start for AVFoundation - stopping");
552
728
  stopAVFoundationRecording();
553
729
  return Napi::Boolean::New(env, false);
554
730
  }
555
- MRLog(@"✅ SYNC: Camera recording started");
556
731
  }
557
732
 
558
733
  // NOTE: Audio is handled internally by AVFoundation, no need for standalone audio
559
734
  // AVFoundation integrates audio recording directly
560
735
 
561
736
  g_isRecording = true;
737
+ MRMarkRecordingStartTimestamp();
738
+ MRStoreActiveStopLimit(-1.0);
562
739
  return Napi::Boolean::New(env, true);
563
740
  } else {
564
741
  NSLog(@"❌ AVFoundation recording failed to start");
@@ -569,9 +746,9 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
569
746
  NSLog(@"❌ Stack trace: %@", [avException callStackSymbols]);
570
747
 
571
748
  // Cleanup camera on exception
572
- if (isCameraRecording()) {
573
- stopCameraRecording();
574
- }
749
+ if (isCameraRecording()) {
750
+ stopCameraRecording();
751
+ }
575
752
  }
576
753
 
577
754
  // Both ScreenCaptureKit and AVFoundation failed
@@ -590,18 +767,32 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
590
767
 
591
768
  MRLog(@"📞 StopRecording native method called");
592
769
 
593
- double stopLimitSeconds = -1.0;
770
+ double requestedStopLimit = -1.0;
771
+ bool explicitStopLimit = false;
594
772
  if (info.Length() > 0 && info[0].IsNumber()) {
595
- stopLimitSeconds = info[0].As<Napi::Number>().DoubleValue();
596
- if (stopLimitSeconds > 0) {
597
- MRLog(@"⏲️ Requested stop limit: %.3f seconds", stopLimitSeconds);
598
- MRSyncSetStopLimitSeconds(stopLimitSeconds);
599
- } else {
600
- MRSyncSetStopLimitSeconds(-1.0);
773
+ requestedStopLimit = info[0].As<Napi::Number>().DoubleValue();
774
+ if (requestedStopLimit > 0) {
775
+ explicitStopLimit = true;
776
+ MRLog(@"⏲️ Requested stop limit: %.3f seconds", requestedStopLimit);
777
+ }
778
+ }
779
+
780
+ if (!explicitStopLimit) {
781
+ double autoLimit = MRComputeElapsedRecordingSeconds();
782
+ if (autoLimit > 0) {
783
+ requestedStopLimit = autoLimit;
784
+ MRLog(@"⏲️ Auto stop limit applied: %.3f seconds", requestedStopLimit);
601
785
  }
786
+ }
787
+
788
+ if (requestedStopLimit > 0) {
789
+ MRStoreActiveStopLimit(requestedStopLimit);
790
+ MRSyncSetStopLimitSeconds(requestedStopLimit);
602
791
  } else {
792
+ MRStoreActiveStopLimit(-1.0);
603
793
  MRSyncSetStopLimitSeconds(-1.0);
604
794
  }
795
+ MRResetRecordingStartTimestamp();
605
796
 
606
797
  // Try ScreenCaptureKit first
607
798
  if (@available(macOS 12.3, *)) {
@@ -619,6 +810,18 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
619
810
  }
620
811
  }
621
812
 
813
+ // Stop standalone microphone if it was used (macOS 13/14)
814
+ if (g_usingStandaloneAudio && isStandaloneAudioRecording()) {
815
+ MRLog(@"🛑 Stopping standalone microphone recording...");
816
+ bool micStopped = stopStandaloneAudioRecording();
817
+ if (micStopped) {
818
+ MRLog(@"✅ Standalone microphone stopped successfully");
819
+ } else {
820
+ MRLog(@"⚠️ Standalone microphone stop may have timed out");
821
+ }
822
+ g_usingStandaloneAudio = false;
823
+ }
824
+
622
825
  // Now stop ScreenCaptureKit (asynchronous)
623
826
  // WARNING: [ScreenCaptureKitRecorder stopRecording] is ASYNC!
624
827
  // It will set g_isRecording = NO in its completion handler
@@ -627,7 +830,6 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
627
830
  // DO NOT set g_isRecording here - let ScreenCaptureKit completion handler do it
628
831
  // Otherwise we have a race condition where JS thinks recording stopped but it's still running
629
832
  g_usingStandaloneAudio = false;
630
- MRSyncSetStopLimitSeconds(-1.0);
631
833
  return Napi::Boolean::New(env, true);
632
834
  }
633
835
  }
@@ -696,7 +898,6 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
696
898
 
697
899
  g_isRecording = false;
698
900
  g_usingStandaloneAudio = false;
699
- MRSyncSetStopLimitSeconds(-1.0);
700
901
 
701
902
  if (avFoundationStopped && (!cameraWasRecording || cameraStopResult)) {
702
903
  MRLog(@"✅ AVFoundation recording stopped");
@@ -710,6 +911,7 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
710
911
  NSLog(@"❌ Exception stopping AVFoundation: %@", exception.reason);
711
912
  g_isRecording = false;
712
913
  g_usingStandaloneAudio = false;
914
+ MRSyncSetStopLimitSeconds(-1.0);
713
915
  return Napi::Boolean::New(env, false);
714
916
  }
715
917
 
@@ -1071,25 +1273,49 @@ Napi::Value GetDisplays(const Napi::CallbackInfo& info) {
1071
1273
  }
1072
1274
  }
1073
1275
 
1276
+ // NAPI Function: Get Video Start Timestamp
1277
+ Napi::Value GetVideoStartTimestamp(const Napi::CallbackInfo& info) {
1278
+ Napi::Env env = info.Env();
1279
+
1280
+ if (@available(macOS 12.3, *)) {
1281
+ NSTimeInterval timestamp = [ScreenCaptureKitRecorder getVideoStartTimestamp];
1282
+ if (timestamp > 0) {
1283
+ return Napi::Number::New(env, timestamp);
1284
+ }
1285
+ }
1286
+
1287
+ // Fallback: return 0 if not available
1288
+ return Napi::Number::New(env, 0);
1289
+ }
1290
+
1074
1291
  // NAPI Function: Get Recording Status
1075
1292
  Napi::Value GetRecordingStatus(const Napi::CallbackInfo& info) {
1076
1293
  Napi::Env env = info.Env();
1077
-
1078
- // Check recording methods
1079
- bool isRecording = g_isRecording;
1080
-
1294
+
1295
+ // CRITICAL: For ScreenCaptureKit, ONLY return true when FULLY initialized
1296
+ // This ensures camera/cursor sync doesn't start until ScreenCaptureKit is ready
1081
1297
  if (@available(macOS 12.3, *)) {
1082
- if ([ScreenCaptureKitRecorder isRecording]) {
1083
- isRecording = true;
1298
+ BOOL sckRecording = [ScreenCaptureKitRecorder isRecording];
1299
+ if (sckRecording) {
1300
+ // Return true ONLY if fully initialized (first frames captured)
1301
+ BOOL fullyInit = [ScreenCaptureKitRecorder isFullyInitialized];
1302
+ static int logCount = 0;
1303
+ if (logCount++ < 3) {
1304
+ NSLog(@"🔍 GetRecordingStatus: SCK recording=%d, fullyInit=%d", sckRecording, fullyInit);
1305
+ }
1306
+ return Napi::Boolean::New(env, fullyInit);
1084
1307
  }
1085
1308
  }
1086
-
1309
+
1087
1310
  // Check AVFoundation (supports both Node.js and Electron)
1088
1311
  if (isAVFoundationRecording()) {
1089
- isRecording = true;
1312
+ NSLog(@"🔍 GetRecordingStatus: Using AVFoundation → true");
1313
+ return Napi::Boolean::New(env, true);
1090
1314
  }
1091
-
1092
- return Napi::Boolean::New(env, isRecording);
1315
+
1316
+ // Fallback to global recording flag
1317
+ NSLog(@"🔍 GetRecordingStatus: Fallback g_isRecording=%d", g_isRecording);
1318
+ return Napi::Boolean::New(env, g_isRecording);
1093
1319
  }
1094
1320
 
1095
1321
  // NAPI Function: Get Window Thumbnail
@@ -1338,15 +1564,25 @@ Napi::Value CheckPermissions(const Napi::CallbackInfo& info) {
1338
1564
  BOOL isM14Plus = (osVersion.majorVersion >= 14);
1339
1565
  BOOL isM13Plus = (osVersion.majorVersion >= 13);
1340
1566
 
1341
- // Check for force AVFoundation flag
1342
- BOOL forceAVFoundation = (getenv("FORCE_AVFOUNDATION") != NULL);
1343
-
1344
- // Electron detection
1567
+ // Electron detection and preference flags (mirror StartRecording logic)
1568
+ BOOL isElectron = (NSBundle.mainBundle.bundleIdentifier &&
1569
+ [NSBundle.mainBundle.bundleIdentifier containsString:@"electron"]) ||
1570
+ (NSProcessInfo.processInfo.processName &&
1571
+ [NSProcessInfo.processInfo.processName containsString:@"Electron"]) ||
1572
+ (NSProcessInfo.processInfo.environment[@"ELECTRON_RUN_AS_NODE"] != nil) ||
1573
+ (NSBundle.mainBundle.bundlePath &&
1574
+ [NSBundle.mainBundle.bundlePath containsString:@"Electron"]);
1575
+
1576
+ BOOL preferSCKEnv = (getenv("PREFER_SCREENCAPTUREKIT") != NULL) ||
1577
+ (getenv("USE_SCREENCAPTUREKIT") != NULL) ||
1578
+ (getenv("FORCE_SCREENCAPTUREKIT") != NULL);
1579
+
1345
1580
  NSLog(@"🔒 Permission check for macOS %ld.%ld.%ld",
1346
1581
  (long)osVersion.majorVersion, (long)osVersion.minorVersion, (long)osVersion.patchVersion);
1347
1582
 
1348
- // Determine which framework will be used (Electron fully supported!)
1349
- BOOL willUseScreenCaptureKit = (isM15Plus && !forceAVFoundation); // Electron can use ScreenCaptureKit on macOS 15+
1583
+ // Determine which framework will be used (same policy as StartRecording)
1584
+ BOOL forceAVFoundationEnv = (getenv("FORCE_AVFOUNDATION") != NULL);
1585
+ BOOL willUseScreenCaptureKit = (isM14Plus && !forceAVFoundationEnv);
1350
1586
  BOOL willUseAVFoundation = (!willUseScreenCaptureKit && (isM13Plus || isM14Plus));
1351
1587
 
1352
1588
  if (willUseScreenCaptureKit) {
@@ -1420,6 +1656,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
1420
1656
  exports.Set(Napi::String::New(env, "getDisplays"), Napi::Function::New(env, GetDisplays));
1421
1657
  exports.Set(Napi::String::New(env, "getWindows"), Napi::Function::New(env, GetWindows));
1422
1658
  exports.Set(Napi::String::New(env, "getRecordingStatus"), Napi::Function::New(env, GetRecordingStatus));
1659
+ exports.Set(Napi::String::New(env, "getVideoStartTimestamp"), Napi::Function::New(env, GetVideoStartTimestamp));
1423
1660
  exports.Set(Napi::String::New(env, "checkPermissions"), Napi::Function::New(env, CheckPermissions));
1424
1661
 
1425
1662
  // Thumbnail functions
@@ -6,11 +6,24 @@ API_AVAILABLE(macos(12.3))
6
6
  @interface ScreenCaptureKitRecorder : NSObject
7
7
 
8
8
  + (BOOL)isScreenCaptureKitAvailable;
9
- + (BOOL)startRecordingWithConfiguration:(NSDictionary *)config
10
- delegate:(id)delegate
11
- error:(NSError **)error;
12
- + (void)stopRecording;
13
- + (BOOL)isRecording;
9
+
10
+ // MULTI-SESSION API: New session-based recording
11
+ + (NSString *)startRecordingWithConfiguration:(NSDictionary *)config
12
+ delegate:(id)delegate
13
+ error:(NSError **)error; // Returns sessionId
14
+ + (BOOL)stopRecording:(NSString *)sessionId; // Stop specific session
15
+ + (BOOL)isRecording:(NSString *)sessionId; // Check specific session
16
+ + (BOOL)isFullyInitialized:(NSString *)sessionId; // Check if session's first frames received
17
+ + (NSTimeInterval)getVideoStartTimestamp:(NSString *)sessionId; // Get session's video start timestamp
18
+ + (NSArray<NSString *> *)getActiveSessions; // Get all active session IDs
19
+ + (NSInteger)getActiveSessionCount; // Get number of active sessions
20
+
21
+ // LEGACY API: For backward compatibility (uses implicit default session)
22
+ + (void)stopRecording; // Stops all sessions
23
+ + (BOOL)isRecording; // Returns YES if ANY session is recording
24
+ + (BOOL)isFullyInitialized; // Check if default session initialized
25
+ + (NSTimeInterval)getVideoStartTimestamp; // Get default session timestamp
26
+
14
27
  + (BOOL)setupVideoWriter;
15
28
  + (void)finalizeRecording;
16
29
  + (void)finalizeVideoWriter;