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.
- package/.claude/settings.local.json +29 -1
- package/CREAVIT_CODE_SNIPPETS.md +832 -0
- package/CREAVIT_INTEGRATION.md +590 -0
- package/CURSOR_MAPPING.md +112 -0
- package/DUAL_RECORDING_PLAN.md +243 -0
- package/MULTI_RECORDING.md +270 -0
- package/MultiWindowRecorder.js +546 -0
- package/README.md +51 -0
- package/binding.gyp +1 -0
- package/index-multiprocess.js +238 -0
- package/index.js +174 -19
- package/package.json +1 -1
- package/recorder-worker.js +399 -0
- package/src/audio_mixer.mm +269 -0
- package/src/audio_recorder.mm +9 -0
- package/src/camera_recorder.mm +457 -702
- package/src/cursor_tracker.mm +75 -60
- package/src/mac_recorder.mm +305 -68
- package/src/screen_capture_kit.h +18 -5
- package/src/screen_capture_kit.mm +1113 -433
- package/cursor-data-1751364226346.json +0 -1
- package/cursor-data-1751364314136.json +0 -1
- package/cursor-data.json +0 -1
package/src/mac_recorder.mm
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
571
|
+
MRLog(@"⚡ ELECTRON: macOS 14+ → trying ScreenCaptureKit first");
|
|
400
572
|
} else {
|
|
401
|
-
MRLog(@"✅ macOS
|
|
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"] = @(
|
|
417
|
-
sckConfig[@"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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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 (
|
|
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(@"
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
573
|
-
|
|
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
|
|
770
|
+
double requestedStopLimit = -1.0;
|
|
771
|
+
bool explicitStopLimit = false;
|
|
594
772
|
if (info.Length() > 0 && info[0].IsNumber()) {
|
|
595
|
-
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
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
|
-
//
|
|
1079
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1312
|
+
NSLog(@"🔍 GetRecordingStatus: Using AVFoundation → true");
|
|
1313
|
+
return Napi::Boolean::New(env, true);
|
|
1090
1314
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
//
|
|
1342
|
-
BOOL
|
|
1343
|
-
|
|
1344
|
-
|
|
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 (
|
|
1349
|
-
BOOL
|
|
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
|
package/src/screen_capture_kit.h
CHANGED
|
@@ -6,11 +6,24 @@ API_AVAILABLE(macos(12.3))
|
|
|
6
6
|
@interface ScreenCaptureKitRecorder : NSObject
|
|
7
7
|
|
|
8
8
|
+ (BOOL)isScreenCaptureKitAvailable;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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;
|