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
|
@@ -6,40 +6,310 @@
|
|
|
6
6
|
#import <CoreMedia/CoreMedia.h>
|
|
7
7
|
#import <AudioToolbox/AudioToolbox.h>
|
|
8
8
|
|
|
9
|
-
//
|
|
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;
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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(@"โ ๏ธ
|
|
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
|
|
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 (
|
|
644
|
+
MRLog(@"๐ Audio writer session started @ %.3f (source=%@)",
|
|
645
|
+
CMTimeGetSeconds(presentationTime),
|
|
646
|
+
routeToMicrophoneTrack ? @"microphone" : @"system");
|
|
303
647
|
}
|
|
304
648
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
649
|
+
static int systemNotReadyCount = 0;
|
|
650
|
+
static int microphoneNotReadyCount = 0;
|
|
651
|
+
int *notReadyCounter = routeToMicrophoneTrack ? µphoneNotReadyCount : &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
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
735
|
+
static int systemAppendCount = 0;
|
|
736
|
+
static int microphoneAppendCount = 0;
|
|
737
|
+
int *appendCount = routeToMicrophoneTrack ? µphoneAppendCount : &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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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(@"๐ฌ
|
|
412
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
return
|
|
912
|
+
|
|
913
|
+
AVAssetWriterInput **targetInput = isMicrophone ? &g_microphoneAudioInput : &g_systemAudioInput;
|
|
914
|
+
if (*targetInput) {
|
|
915
|
+
return YES;
|
|
517
916
|
}
|
|
518
|
-
|
|
519
|
-
|
|
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: @(
|
|
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
|
-
|
|
545
|
-
|
|
997
|
+
AVAssetWriterInput *newInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio
|
|
998
|
+
outputSettings:audioSettings];
|
|
999
|
+
newInput.expectsMediaDataInRealTime = YES;
|
|
546
1000
|
|
|
547
|
-
if (![g_audioWriter canAddInput:
|
|
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:
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
return NO;
|
|
571
|
-
}
|
|
1028
|
+
if (!config) {
|
|
1029
|
+
return NO;
|
|
1030
|
+
}
|
|
572
1031
|
|
|
573
|
-
|
|
574
|
-
|
|
1032
|
+
NSDictionary *configCopy = [config copy];
|
|
1033
|
+
dispatch_queue_t controlQueue = ScreenCaptureControlQueue();
|
|
1034
|
+
__block BOOL accepted = NO;
|
|
575
1035
|
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
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
|
-
|
|
623
|
-
if (contentError) {
|
|
1056
|
+
if (contentError || !content) {
|
|
624
1057
|
NSLog(@"โ Content error: %@", contentError);
|
|
625
|
-
|
|
626
|
-
return;
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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;
|
|
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 =
|
|
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
|
-
|
|
773
|
-
|
|
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
|
-
|
|
776
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
867
|
-
if (
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1679
|
+
NSLog(@"๐ CALLING startCaptureWithCompletionHandler (async)...");
|
|
887
1680
|
[g_stream startCaptureWithCompletionHandler:^(NSError *startError) {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
}];
|
|
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
|