node-mac-recorder 2.24.9 → 2.24.11
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/MultiWindowRecorder.js +85 -4
- package/README.md +18 -1
- package/index-multiprocess.js +50 -6
- package/index.js +153 -41
- package/package.json +1 -1
- package/recorder-worker.js +159 -69
- package/recorder_runtime_safety.cjs +101 -0
- package/src/audio_recorder.mm +43 -43
- package/src/avfoundation_recorder.mm +103 -69
- package/src/camera_recorder.mm +69 -78
- package/src/ios_device_recorder.mm +121 -20
- package/src/keyboard_tracker.mm +8 -1
- package/src/mac_recorder.mm +84 -13
- package/src/recording_writer_safety.h +66 -0
- package/src/screen_capture_kit.h +4 -2
- package/src/screen_capture_kit.mm +264 -199
- package/src/sync_timeline.h +9 -0
- package/src/sync_timeline.mm +54 -0
package/src/audio_recorder.mm
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#import "recording_writer_safety.h"
|
|
1
2
|
#import <Foundation/Foundation.h>
|
|
2
3
|
#import <AVFoundation/AVFoundation.h>
|
|
3
4
|
#import "logging.h"
|
|
@@ -250,8 +251,8 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
250
251
|
MRLog(@"🛑 AudioRecorder: Stopping session (external device safe)...");
|
|
251
252
|
|
|
252
253
|
// Stop session on background thread to avoid blocking
|
|
253
|
-
AVCaptureSession *sessionToStop = self.session;
|
|
254
|
-
AVCaptureAudioDataOutput *outputToStop = self.audioOutput;
|
|
254
|
+
AVCaptureSession *sessionToStop = [self.session retain];
|
|
255
|
+
AVCaptureAudioDataOutput *outputToStop = [self.audioOutput retain];
|
|
255
256
|
|
|
256
257
|
// Clear references FIRST to prevent new samples
|
|
257
258
|
self.session = nil;
|
|
@@ -260,51 +261,27 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
260
261
|
// Stop session asynchronously with timeout protection
|
|
261
262
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
|
|
262
263
|
@autoreleasepool {
|
|
264
|
+
@try {
|
|
263
265
|
if ([sessionToStop isRunning]) {
|
|
264
266
|
MRLog(@"🛑 Stopping AVCaptureSession...");
|
|
265
267
|
[sessionToStop stopRunning];
|
|
266
268
|
MRLog(@"✅ AVCaptureSession stopped");
|
|
267
269
|
}
|
|
270
|
+
} @catch (NSException *exception) {
|
|
271
|
+
NSLog(@"[Recorder] Microphone session stop failed safely: %@", exception.reason);
|
|
272
|
+
}
|
|
268
273
|
// Release happens automatically when block completes
|
|
269
274
|
}
|
|
270
275
|
});
|
|
271
276
|
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
|
281
|
-
|
|
282
|
-
[self.writer finishWritingWithCompletionHandler:^{
|
|
283
|
-
finished = YES;
|
|
284
|
-
dispatch_semaphore_signal(semaphore);
|
|
285
|
-
}];
|
|
286
|
-
|
|
287
|
-
// SYNC FIX: Match camera timeout (3 seconds) for consistent finish timing
|
|
288
|
-
const int64_t primaryWaitSeconds = 3;
|
|
289
|
-
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(primaryWaitSeconds * NSEC_PER_SEC));
|
|
290
|
-
long result = dispatch_semaphore_wait(semaphore, timeout);
|
|
291
|
-
|
|
292
|
-
if (result != 0 || !finished) {
|
|
293
|
-
MRLog(@"⚠️ AudioRecorder: Writer still finishing after %ds – waiting longer", (int)primaryWaitSeconds);
|
|
294
|
-
const int64_t extendedWaitSeconds = 5;
|
|
295
|
-
dispatch_time_t extendedTimeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(extendedWaitSeconds * NSEC_PER_SEC));
|
|
296
|
-
result = dispatch_semaphore_wait(semaphore, extendedTimeout);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (result != 0 || !finished) {
|
|
300
|
-
MRLog(@"⚠️ AudioRecorder: Writer did not finish after extended wait – forcing cancel");
|
|
301
|
-
[self.writer cancelWriting];
|
|
302
|
-
} else {
|
|
303
|
-
MRLog(@"✅ AudioRecorder writer finished successfully");
|
|
304
|
-
}
|
|
305
|
-
} else {
|
|
306
|
-
MRLog(@"⚠️ AudioRecorder: No writer to finish (no audio captured)");
|
|
307
|
-
}
|
|
277
|
+
// Detach callbacks before draining; otherwise a late audio sample can
|
|
278
|
+
// recreate or append to a writer which is being finalized.
|
|
279
|
+
[sessionToStop release]; // the copied dispatch block owns it now
|
|
280
|
+
@try { [outputToStop setSampleBufferDelegate:nil queue:nil]; }
|
|
281
|
+
@catch (NSException *exception) { NSLog(@"[Recorder] Microphone delegate detach: %@", exception.reason); }
|
|
282
|
+
[outputToStop release];
|
|
283
|
+
if (g_audioCaptureQueue) dispatch_sync(g_audioCaptureQueue, ^{});
|
|
284
|
+
MRFinishAssetWriterSafely(self.writer, 8.0);
|
|
308
285
|
|
|
309
286
|
self.writer = nil;
|
|
310
287
|
self.writerInput = nil;
|
|
@@ -319,6 +296,11 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
319
296
|
#pragma mark - AVCaptureAudioDataOutputSampleBufferDelegate
|
|
320
297
|
|
|
321
298
|
- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
|
|
299
|
+
if (!self.session || output != self.audioOutput) return;
|
|
300
|
+
@try {
|
|
301
|
+
if (MRSyncIsPaused()) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
322
304
|
if (!CMSampleBufferDataIsReady(sampleBuffer)) {
|
|
323
305
|
return;
|
|
324
306
|
}
|
|
@@ -390,6 +372,7 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
390
372
|
if (CMTIME_COMPARE_INLINE(adjustedPTS, <, kCMTimeZero)) {
|
|
391
373
|
adjustedPTS = kCMTimeZero;
|
|
392
374
|
}
|
|
375
|
+
adjustedPTS = MRSyncAdjustForPauses(adjustedPTS);
|
|
393
376
|
timingInfo[i].presentationTimeStamp = adjustedPTS;
|
|
394
377
|
|
|
395
378
|
if (stopLimit > 0) {
|
|
@@ -409,7 +392,7 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
409
392
|
if (CMTIME_COMPARE_INLINE(adjustedDTS, <, kCMTimeZero)) {
|
|
410
393
|
adjustedDTS = kCMTimeZero;
|
|
411
394
|
}
|
|
412
|
-
timingInfo[i].decodeTimeStamp = adjustedDTS;
|
|
395
|
+
timingInfo[i].decodeTimeStamp = MRSyncAdjustForPauses(adjustedDTS);
|
|
413
396
|
}
|
|
414
397
|
}
|
|
415
398
|
|
|
@@ -433,7 +416,8 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
433
416
|
// No timing info available; approximate using buffer timestamp.
|
|
434
417
|
CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
|
|
435
418
|
if (CMTIME_IS_VALID(pts)) {
|
|
436
|
-
double relativeStart = CMTimeGetSeconds(
|
|
419
|
+
double relativeStart = CMTimeGetSeconds(
|
|
420
|
+
MRSyncAdjustForPauses(CMTimeSubtract(pts, self.startTime)));
|
|
437
421
|
if (relativeStart > stopLimit + audioTolerance) {
|
|
438
422
|
shouldDropBuffer = YES;
|
|
439
423
|
}
|
|
@@ -454,6 +438,10 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
454
438
|
if (bufferToAppend != sampleBuffer) {
|
|
455
439
|
CFRelease(bufferToAppend);
|
|
456
440
|
}
|
|
441
|
+
} @catch (NSException *exception) {
|
|
442
|
+
NSLog(@"[Recorder] Audio sample failed safely: %@", exception.reason);
|
|
443
|
+
}
|
|
444
|
+
|
|
457
445
|
}
|
|
458
446
|
|
|
459
447
|
@end
|
|
@@ -532,8 +520,8 @@ bool startStandaloneAudioRecording(NSString *outputPath,
|
|
|
532
520
|
return false;
|
|
533
521
|
}
|
|
534
522
|
|
|
535
|
-
__block BOOL audioPermissionGranted = YES;
|
|
536
523
|
AVAuthorizationStatus audioStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
|
|
524
|
+
__block BOOL audioPermissionGranted = (audioStatus == AVAuthorizationStatusAuthorized);
|
|
537
525
|
if (audioStatus == AVAuthorizationStatusNotDetermined) {
|
|
538
526
|
dispatch_semaphore_t permissionSemaphore = dispatch_semaphore_create(0);
|
|
539
527
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
|
|
@@ -555,9 +543,21 @@ bool startStandaloneAudioRecording(NSString *outputPath,
|
|
|
555
543
|
|
|
556
544
|
g_audioRecorder = [[NativeAudioRecorder alloc] init];
|
|
557
545
|
if (outputPath && [outputPath length] > 0) {
|
|
558
|
-
|
|
546
|
+
NSString *ownedOutputPath = [outputPath copy];
|
|
547
|
+
[g_lastStandaloneAudioOutputPath release];
|
|
548
|
+
g_lastStandaloneAudioOutputPath = ownedOutputPath;
|
|
549
|
+
}
|
|
550
|
+
@try {
|
|
551
|
+
BOOL started = [g_audioRecorder startRecordingWithDeviceId:preferredDeviceId outputPath:outputPath error:error];
|
|
552
|
+
if (!started) [g_audioRecorder stopRecording];
|
|
553
|
+
return started;
|
|
554
|
+
} @catch (NSException *exception) {
|
|
555
|
+
NSLog(@"[Recorder] Microphone startup failed safely: %@", exception.reason);
|
|
556
|
+
@try { [g_audioRecorder stopRecording]; } @catch (NSException *cleanupError) {}
|
|
557
|
+
if (error) *error = [NSError errorWithDomain:@"NativeAudioRecorder" code:-22
|
|
558
|
+
userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: @"Microphone startup failed"}];
|
|
559
|
+
return false;
|
|
559
560
|
}
|
|
560
|
-
return [g_audioRecorder startRecordingWithDeviceId:preferredDeviceId outputPath:outputPath error:error];
|
|
561
561
|
}
|
|
562
562
|
|
|
563
563
|
bool stopStandaloneAudioRecording() {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
#include <string>
|
|
8
8
|
#import "logging.h"
|
|
9
9
|
#import "sync_timeline.h"
|
|
10
|
+
#import "recording_writer_safety.h"
|
|
11
|
+
#include <atomic>
|
|
10
12
|
|
|
11
13
|
// Import audio recorder
|
|
12
14
|
extern "C" void* createNativeAudioRecorder(void);
|
|
@@ -18,9 +20,11 @@ static AVAssetWriter *g_avWriter = nil;
|
|
|
18
20
|
static AVAssetWriterInput *g_avVideoInput = nil;
|
|
19
21
|
static AVAssetWriterInputPixelBufferAdaptor *g_avPixelBufferAdaptor = nil;
|
|
20
22
|
static dispatch_source_t g_avTimer = nil;
|
|
23
|
+
static dispatch_queue_t g_avCaptureQueue = nil;
|
|
24
|
+
static std::atomic<bool> g_avIsStopping(false);
|
|
21
25
|
static CGDirectDisplayID g_avDisplayID = 0;
|
|
22
26
|
static CGRect g_avCaptureRect = CGRectZero;
|
|
23
|
-
static bool g_avIsRecording
|
|
27
|
+
static std::atomic<bool> g_avIsRecording(false);
|
|
24
28
|
static int64_t g_avFrameNumber = 0;
|
|
25
29
|
static CMTime g_avStartTime;
|
|
26
30
|
static void* g_avAudioRecorder = nil;
|
|
@@ -123,6 +127,10 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
123
127
|
MRLog(@"🔍 Scale factor: %.1fx → Standard display", scaleFactor);
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
// H.264 4:2:0 CIFT boyut ister — bkz. screen_capture_kit.mm'deki ayni guard.
|
|
131
|
+
recordingSize.width = MAX(2.0, floor(recordingSize.width / 2.0) * 2.0);
|
|
132
|
+
recordingSize.height = MAX(2.0, floor(recordingSize.height / 2.0) * 2.0);
|
|
133
|
+
|
|
126
134
|
MRLog(@"🎯 Recording size: %.0fx%.0f (using actual physical dimensions for Retina fix)", recordingSize.width, recordingSize.height);
|
|
127
135
|
|
|
128
136
|
NSString *normalizedQuality = @"high";
|
|
@@ -140,7 +148,6 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
140
148
|
|
|
141
149
|
NSString *codecKey = AVVideoCodecTypeH264;
|
|
142
150
|
NSInteger bitrate = kAVFoundationHighQualityVideoBitrate;
|
|
143
|
-
NSNumber *qualityHint = @1.0;
|
|
144
151
|
|
|
145
152
|
if ([normalizedQuality isEqualToString:@"low"]) {
|
|
146
153
|
NSInteger multiplier = 10;
|
|
@@ -149,7 +156,6 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
149
156
|
bitrate = (NSInteger)(recordingSize.width * recordingSize.height * multiplier);
|
|
150
157
|
bitrate = MAX(bitrate, minBitrate);
|
|
151
158
|
bitrate = MIN(bitrate, maxBitrate);
|
|
152
|
-
qualityHint = @0.85;
|
|
153
159
|
} else if ([normalizedQuality isEqualToString:@"medium"]) {
|
|
154
160
|
NSInteger multiplier = 18;
|
|
155
161
|
NSInteger minBitrate = 18 * 1000 * 1000;
|
|
@@ -157,7 +163,6 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
157
163
|
bitrate = (NSInteger)(recordingSize.width * recordingSize.height * multiplier);
|
|
158
164
|
bitrate = MAX(bitrate, minBitrate);
|
|
159
165
|
bitrate = MIN(bitrate, maxBitrate);
|
|
160
|
-
qualityHint = @0.9;
|
|
161
166
|
} else { // high - çözünürlüğe DUYARLI yüksek kalite (eski sabit 50 Mbps yerine)
|
|
162
167
|
NSInteger multiplier = 32; // ~0.53 bpp @60fps
|
|
163
168
|
NSInteger minBitrate = kAVFoundationHighQualityVideoBitrate; // 100 Mbps taban
|
|
@@ -165,7 +170,6 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
165
170
|
bitrate = (NSInteger)(recordingSize.width * recordingSize.height * multiplier);
|
|
166
171
|
bitrate = MAX(bitrate, minBitrate);
|
|
167
172
|
bitrate = MIN(bitrate, maxBitrate);
|
|
168
|
-
qualityHint = @1.0;
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
MRLog(@"🎬 AVFoundation encoder (%@): %dx%d, codec=H.264 High Profile, bitrate=%.2fMbps",
|
|
@@ -183,14 +187,65 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
183
187
|
AVVideoMaxKeyFrameIntervalKey: @((int)fps),
|
|
184
188
|
AVVideoAllowFrameReorderingKey: @YES,
|
|
185
189
|
AVVideoExpectedSourceFrameRateKey: @((int)fps),
|
|
186
|
-
AVVideoQualityKey:
|
|
190
|
+
// AVVideoQualityKey BILEREK YOK — bkz. screen_capture_kit.mm:
|
|
191
|
+
// avc1 icin gecersiz, yazilim encoder yolunda AVAssetWriterInput
|
|
192
|
+
// istisna atip kaydi sessizce bos birakiyor.
|
|
187
193
|
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
|
|
188
194
|
AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCABAC
|
|
189
195
|
}
|
|
190
196
|
};
|
|
191
197
|
|
|
192
|
-
// Create video input
|
|
193
|
-
|
|
198
|
+
// Create video input — kademeli geri dusus (bkz. screen_capture_kit.mm).
|
|
199
|
+
// AVFoundation reddettigi sikistirma ozelliginde NSError degil ISTISNA
|
|
200
|
+
// atiyor; yakalanmazsa kayit sessizce bos cikiyor.
|
|
201
|
+
NSArray<NSDictionary *> *settingsTiers = @[
|
|
202
|
+
videoSettings,
|
|
203
|
+
@{
|
|
204
|
+
AVVideoCodecKey: codecKey,
|
|
205
|
+
AVVideoWidthKey: @((int)recordingSize.width),
|
|
206
|
+
AVVideoHeightKey: @((int)recordingSize.height),
|
|
207
|
+
AVVideoCompressionPropertiesKey: @{
|
|
208
|
+
AVVideoAverageBitRateKey: @(bitrate),
|
|
209
|
+
AVVideoMaxKeyFrameIntervalKey: @((int)fps),
|
|
210
|
+
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
@{
|
|
214
|
+
AVVideoCodecKey: codecKey,
|
|
215
|
+
AVVideoWidthKey: @((int)recordingSize.width),
|
|
216
|
+
AVVideoHeightKey: @((int)recordingSize.height),
|
|
217
|
+
AVVideoCompressionPropertiesKey: @{ AVVideoAverageBitRateKey: @(bitrate) }
|
|
218
|
+
},
|
|
219
|
+
@{
|
|
220
|
+
AVVideoCodecKey: codecKey,
|
|
221
|
+
AVVideoWidthKey: @((int)recordingSize.width),
|
|
222
|
+
AVVideoHeightKey: @((int)recordingSize.height)
|
|
223
|
+
}
|
|
224
|
+
];
|
|
225
|
+
g_avVideoInput = nil;
|
|
226
|
+
NSString *lastRejection = nil;
|
|
227
|
+
for (NSUInteger tier = 0; tier < settingsTiers.count; tier++) {
|
|
228
|
+
@try {
|
|
229
|
+
g_avVideoInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo
|
|
230
|
+
outputSettings:settingsTiers[tier]];
|
|
231
|
+
} @catch (NSException *exception) {
|
|
232
|
+
g_avVideoInput = nil;
|
|
233
|
+
lastRejection = exception.reason;
|
|
234
|
+
NSLog(@"⚠️ AVFoundation encoder settings tier %lu rejected: %@", (unsigned long)tier, exception.reason);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (g_avVideoInput) {
|
|
238
|
+
if (tier > 0) {
|
|
239
|
+
NSLog(@"⚠️ AVFoundation encoder fell back to settings tier %lu; last rejection: %@",
|
|
240
|
+
(unsigned long)tier, lastRejection);
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!g_avVideoInput) {
|
|
246
|
+
NSLog(@"❌ AVFoundation: no accepted H.264 encoder settings (%@)", lastRejection ?: @"unknown");
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
194
249
|
g_avVideoInput.expectsMediaDataInRealTime = YES;
|
|
195
250
|
|
|
196
251
|
// Create pixel buffer adaptor with compatibility
|
|
@@ -310,6 +365,7 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
310
365
|
|
|
311
366
|
// Start capture timer using target FPS
|
|
312
367
|
dispatch_queue_t captureQueue = dispatch_queue_create("AVFoundationCaptureQueue", DISPATCH_QUEUE_SERIAL);
|
|
368
|
+
g_avCaptureQueue = captureQueue;
|
|
313
369
|
g_avTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, captureQueue);
|
|
314
370
|
|
|
315
371
|
if (!g_avTimer) {
|
|
@@ -326,6 +382,7 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
326
382
|
|
|
327
383
|
dispatch_source_set_event_handler(g_avTimer, ^{
|
|
328
384
|
if (!g_avIsRecording) return;
|
|
385
|
+
if (MRSyncIsPaused()) return;
|
|
329
386
|
|
|
330
387
|
// Additional null checks for Electron safety
|
|
331
388
|
if (!localVideoInput || !localPixelBufferAdaptor) {
|
|
@@ -431,7 +488,8 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
431
488
|
if (!CMTIME_IS_VALID(relativeTime) || CMTIME_COMPARE_INLINE(relativeTime, <, kCMTimeZero)) {
|
|
432
489
|
relativeTime = kCMTimeZero;
|
|
433
490
|
}
|
|
434
|
-
CMTime presentationTime =
|
|
491
|
+
CMTime presentationTime = MRSyncAdjustForPauses(
|
|
492
|
+
CMTimeMakeWithSeconds(CMTimeGetSeconds(relativeTime), 600));
|
|
435
493
|
|
|
436
494
|
double stopLimit = MRSyncGetStopLimitSeconds();
|
|
437
495
|
if (stopLimit > 0) {
|
|
@@ -490,75 +548,51 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
490
548
|
}
|
|
491
549
|
|
|
492
550
|
extern "C" bool stopAVFoundationRecording() {
|
|
493
|
-
if (
|
|
494
|
-
return true;
|
|
495
|
-
}
|
|
496
|
-
|
|
551
|
+
if (g_avIsStopping.exchange(true)) return false;
|
|
497
552
|
g_avIsRecording = false;
|
|
498
|
-
|
|
499
|
-
// Stop audio recording if active
|
|
500
|
-
if (g_avAudioRecorder) {
|
|
501
|
-
MRLog(@"🛑 Stopping audio recording");
|
|
502
|
-
@try {
|
|
503
|
-
stopNativeAudioRecording(g_avAudioRecorder);
|
|
504
|
-
destroyNativeAudioRecorder(g_avAudioRecorder);
|
|
505
|
-
} @catch (NSException *exception) {
|
|
506
|
-
NSLog(@"⚠️ Exception while stopping audio: %@", exception.reason);
|
|
507
|
-
}
|
|
508
|
-
g_avAudioRecorder = nil;
|
|
509
|
-
g_avAudioOutputPath = nil;
|
|
510
|
-
MRLog(@"✅ Audio recording stopped");
|
|
511
|
-
}
|
|
512
|
-
|
|
553
|
+
BOOL finished = YES;
|
|
513
554
|
@try {
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
MRLog(@"✅ AVFoundation timer stopped safely");
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Finish writing with null checks
|
|
532
|
-
AVAssetWriterInput *writerInput = g_avVideoInput;
|
|
533
|
-
if (writerInput) {
|
|
534
|
-
[writerInput markAsFinished];
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
AVAssetWriter *writer = g_avWriter;
|
|
538
|
-
if (writer && writer.status == AVAssetWriterStatusWriting) {
|
|
539
|
-
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
|
540
|
-
[writer finishWritingWithCompletionHandler:^{
|
|
541
|
-
dispatch_semaphore_signal(semaphore);
|
|
542
|
-
}];
|
|
543
|
-
// Add timeout to prevent infinite wait in Electron
|
|
544
|
-
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC);
|
|
545
|
-
dispatch_semaphore_wait(semaphore, timeout);
|
|
555
|
+
// Cancelling a timer does not wait for an in-flight frame. Drain its
|
|
556
|
+
// serial queue before finishing or releasing the writer it uses.
|
|
557
|
+
if (g_avTimer) dispatch_source_cancel(g_avTimer);
|
|
558
|
+
if (g_avCaptureQueue) dispatch_sync(g_avCaptureQueue, ^{});
|
|
559
|
+
if (g_avAudioRecorder) {
|
|
560
|
+
@try {
|
|
561
|
+
stopNativeAudioRecording(g_avAudioRecorder);
|
|
562
|
+
destroyNativeAudioRecorder(g_avAudioRecorder);
|
|
563
|
+
} @catch (NSException *exception) {
|
|
564
|
+
NSLog(@"[Recorder] Fallback audio stop failed safely: %@", exception.reason);
|
|
565
|
+
}
|
|
566
|
+
g_avAudioRecorder = nil;
|
|
567
|
+
[g_avAudioOutputPath release];
|
|
568
|
+
g_avAudioOutputPath = nil;
|
|
546
569
|
}
|
|
547
|
-
|
|
548
|
-
|
|
570
|
+
finished = MRFinishAssetWriterSafely(g_avWriter, 5.0);
|
|
571
|
+
} @catch (NSException *exception) {
|
|
572
|
+
NSLog(@"[Recorder] Fallback stop failed safely: %@", exception.reason);
|
|
573
|
+
finished = NO;
|
|
574
|
+
} @finally {
|
|
575
|
+
[g_avWriter release];
|
|
549
576
|
g_avWriter = nil;
|
|
550
577
|
g_avVideoInput = nil;
|
|
551
578
|
g_avPixelBufferAdaptor = nil;
|
|
579
|
+
if (g_avTimer) dispatch_release(g_avTimer);
|
|
580
|
+
g_avTimer = nil;
|
|
581
|
+
if (g_avCaptureQueue) dispatch_release(g_avCaptureQueue);
|
|
582
|
+
g_avCaptureQueue = nil;
|
|
552
583
|
g_avFrameNumber = 0;
|
|
553
584
|
g_avStartTime = kCMTimeInvalid;
|
|
554
|
-
|
|
555
|
-
MRLog(@"✅ AVFoundation recording stopped");
|
|
556
|
-
return true;
|
|
557
|
-
|
|
558
|
-
} @catch (NSException *exception) {
|
|
559
|
-
NSLog(@"❌ Exception stopping AVFoundation recording: %@", exception.reason);
|
|
560
|
-
return false;
|
|
585
|
+
g_avIsStopping = false;
|
|
561
586
|
}
|
|
587
|
+
return finished;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
extern "C" bool hasAVFoundationRecordingResources() {
|
|
591
|
+
return g_avIsRecording || g_avIsStopping || g_avWriter != nil || g_avTimer != nil || g_avAudioRecorder != nil;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
extern "C" bool isAVFoundationRecordingStopping() {
|
|
595
|
+
return g_avIsStopping;
|
|
562
596
|
}
|
|
563
597
|
|
|
564
598
|
extern "C" bool isAVFoundationRecording() {
|
package/src/camera_recorder.mm
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#import "recording_writer_safety.h"
|
|
1
2
|
#import <AVFoundation/AVFoundation.h>
|
|
2
3
|
#import <CoreMedia/CoreMedia.h>
|
|
3
4
|
#import <CoreVideo/CoreVideo.h>
|
|
@@ -506,6 +507,12 @@ static void MRCameraRemoveFileIfExists(NSString *path) {
|
|
|
506
507
|
- (void)captureOutput:(AVCaptureOutput *)output
|
|
507
508
|
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
508
509
|
fromConnection:(AVCaptureConnection *)connection {
|
|
510
|
+
if (self.stopInFlight || !self.isRecording || output != self.videoOutput) return;
|
|
511
|
+
@try {
|
|
512
|
+
|
|
513
|
+
if (MRSyncIsPaused()) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
509
516
|
|
|
510
517
|
if (!CMSampleBufferDataIsReady(sampleBuffer)) {
|
|
511
518
|
return;
|
|
@@ -535,6 +542,12 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
535
542
|
// A/V SYNC: Signal camera's first frame to release audio hold
|
|
536
543
|
MRSyncMarkCameraFirstFrame(timestamp);
|
|
537
544
|
|
|
545
|
+
// "Ready" means the capture device delivered a real frame, not that the
|
|
546
|
+
// writer already received audio. Signaling here lets the primary recorder
|
|
547
|
+
// start its audio source; waiting until writer start creates a camera/audio
|
|
548
|
+
// barrier cycle and can hold startup until the timeout.
|
|
549
|
+
[self completeStart:YES token:self.activeToken];
|
|
550
|
+
|
|
538
551
|
// Hold camera frames until we see audio so timelines stay aligned
|
|
539
552
|
if (MRSyncShouldHoldVideoFrame(timestamp)) {
|
|
540
553
|
return;
|
|
@@ -573,8 +586,6 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
573
586
|
|
|
574
587
|
g_cameraStartTimestamp = CFAbsoluteTimeGetCurrent();
|
|
575
588
|
|
|
576
|
-
// Signal start completion
|
|
577
|
-
[self completeStart:YES token:self.activeToken];
|
|
578
589
|
}
|
|
579
590
|
|
|
580
591
|
if (!self.writerInput.readyForMoreMediaData) {
|
|
@@ -595,6 +606,7 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
595
606
|
// This should not happen if sync is working correctly
|
|
596
607
|
adjustedTimestamp = kCMTimeZero;
|
|
597
608
|
}
|
|
609
|
+
adjustedTimestamp = MRSyncAdjustForPauses(adjustedTimestamp);
|
|
598
610
|
|
|
599
611
|
// LIP SYNC FIX: Check stopLimit OR elapsed time to drop frames after recording duration
|
|
600
612
|
// This prevents camera from recording longer than audio
|
|
@@ -604,7 +616,9 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
604
616
|
|
|
605
617
|
// CRITICAL FIX: Also check elapsed time since recording started
|
|
606
618
|
// This works even if stopLimit hasn't been set yet
|
|
607
|
-
double elapsedTime = (g_cameraStartTimestamp > 0)
|
|
619
|
+
double elapsedTime = (g_cameraStartTimestamp > 0)
|
|
620
|
+
? MAX(0, CFAbsoluteTimeGetCurrent() - g_cameraStartTimestamp - MRSyncGetPausedDurationSeconds())
|
|
621
|
+
: 0;
|
|
608
622
|
double maxDuration = (stopLimit > 0) ? stopLimit : elapsedTime + 1.0; // Use stopLimit if available, else allow 1s more
|
|
609
623
|
|
|
610
624
|
// DEBUG: Log every 30th frame
|
|
@@ -642,6 +656,12 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
642
656
|
if (!success) {
|
|
643
657
|
MRLog(@"⚠️ Failed to append camera pixel buffer: %@", self.writer.error);
|
|
644
658
|
}
|
|
659
|
+
} @catch (NSException *exception) {
|
|
660
|
+
NSLog(@"[Recorder] Camera sample failed safely: %@", exception.reason);
|
|
661
|
+
[self completeStart:NO token:self.activeToken];
|
|
662
|
+
self.isRecording = NO;
|
|
663
|
+
}
|
|
664
|
+
|
|
645
665
|
}
|
|
646
666
|
|
|
647
667
|
#pragma mark - Synchronization helpers
|
|
@@ -655,7 +675,8 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
655
675
|
|
|
656
676
|
- (BOOL)waitForStopCompletion:(NSTimeInterval)timeout {
|
|
657
677
|
dispatch_semaphore_t stopSemaphore = self.stopSemaphore;
|
|
658
|
-
if (!stopSemaphore) {
|
|
678
|
+
if (!stopSemaphore || !self.stopInFlight) {
|
|
679
|
+
self.stopSemaphore = nil;
|
|
659
680
|
return YES;
|
|
660
681
|
}
|
|
661
682
|
dispatch_time_t waitTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC));
|
|
@@ -884,96 +905,57 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
884
905
|
uint64_t token = [self nextToken];
|
|
885
906
|
|
|
886
907
|
dispatch_async(self.workQueue, ^{
|
|
887
|
-
|
|
908
|
+
@try {
|
|
909
|
+
[self performStartWithDeviceId:deviceId outputPath:outputPath token:token];
|
|
910
|
+
} @catch (NSException *exception) {
|
|
911
|
+
NSLog(@"[Recorder] Camera startup failed safely: %@", exception.reason);
|
|
912
|
+
[self completeStart:NO token:token];
|
|
913
|
+
}
|
|
888
914
|
});
|
|
889
915
|
|
|
890
916
|
return YES;
|
|
891
917
|
}
|
|
892
918
|
|
|
893
919
|
- (BOOL)stopRecording {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
if (!self.
|
|
897
|
-
[self waitForStopCompletion:5.0];
|
|
898
|
-
return YES;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
if (!self.startCompleted) {
|
|
902
|
-
[self completeStart:NO token:self.activeToken];
|
|
903
|
-
}
|
|
904
|
-
|
|
920
|
+
if (self.stopInFlight) return [self waitForStopCompletion:5.0];
|
|
921
|
+
if (!self.isRecording && !self.session && !self.writer) return YES;
|
|
922
|
+
if (!self.startCompleted) [self completeStart:NO token:self.activeToken];
|
|
905
923
|
self.stopInFlight = YES;
|
|
924
|
+
self.isRecording = NO;
|
|
925
|
+
[self nextToken]; // invalidate any queued startup work
|
|
906
926
|
|
|
907
927
|
dispatch_semaphore_t stopSemaphore = dispatch_semaphore_create(0);
|
|
908
928
|
self.stopSemaphore = stopSemaphore;
|
|
909
|
-
|
|
910
929
|
dispatch_async(self.workQueue, ^{
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
if (self.writer && self.writerStarted) {
|
|
918
|
-
// LIP SYNC FIX: End session at stopLimit to trim excess frames
|
|
919
|
-
double stopLimit = MRSyncGetStopLimitSeconds();
|
|
920
|
-
if (stopLimit > 0) {
|
|
921
|
-
CMTime endTime = CMTimeMakeWithSeconds(stopLimit, 600);
|
|
922
|
-
[self.writer endSessionAtSourceTime:endTime];
|
|
923
|
-
MRLog(@"🎯 Camera writer session ended at %.3fs (stopLimit)", stopLimit);
|
|
930
|
+
@autoreleasepool {
|
|
931
|
+
@try {
|
|
932
|
+
@try {
|
|
933
|
+
if (self.videoOutput) [self.videoOutput setSampleBufferDelegate:nil queue:nil];
|
|
934
|
+
} @catch (NSException *exception) {
|
|
935
|
+
NSLog(@"[Recorder] Camera delegate detach failed safely: %@", exception.reason);
|
|
924
936
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
937
|
+
// A callback already running may still use the writer/adaptor.
|
|
938
|
+
dispatch_sync(self.videoQueue, ^{});
|
|
939
|
+
MRFinishAssetWriterSafely(self.writer, 3.0, MRSyncGetStopLimitSeconds());
|
|
940
|
+
if (self.session.isRunning) [self.session stopRunning];
|
|
941
|
+
if (self.deviceInput && [self.session.inputs containsObject:self.deviceInput]) {
|
|
942
|
+
[self.session removeInput:self.deviceInput];
|
|
928
943
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
[self.writer finishWritingWithCompletionHandler:^{
|
|
932
|
-
if (self.writer.status == AVAssetWriterStatusCompleted) {
|
|
933
|
-
MRLog(@"✅ Camera writer finished");
|
|
934
|
-
} else if (self.writer.status == AVAssetWriterStatusFailed) {
|
|
935
|
-
MRLog(@"❌ Camera writer failed: %@", self.writer.error);
|
|
936
|
-
}
|
|
937
|
-
dispatch_semaphore_signal(writerSemaphore);
|
|
938
|
-
}];
|
|
939
|
-
|
|
940
|
-
// 3 second timeout (matching audio_recorder.mm:269)
|
|
941
|
-
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC);
|
|
942
|
-
if (dispatch_semaphore_wait(writerSemaphore, timeout) != 0) {
|
|
943
|
-
MRLog(@"⚠️ Camera writer timeout – canceling");
|
|
944
|
-
[self.writer cancelWriting];
|
|
944
|
+
if (self.videoOutput && [self.session.outputs containsObject:self.videoOutput]) {
|
|
945
|
+
[self.session removeOutput:self.videoOutput];
|
|
945
946
|
}
|
|
947
|
+
} @catch (NSException *exception) {
|
|
948
|
+
NSLog(@"[Recorder] Camera stop failed safely: %@", exception.reason);
|
|
949
|
+
} @finally {
|
|
950
|
+
[self cleanupAfterStopOnQueue];
|
|
951
|
+
// Signal only after the old session can no longer clear new state.
|
|
952
|
+
dispatch_semaphore_signal(stopSemaphore);
|
|
946
953
|
}
|
|
947
|
-
|
|
948
|
-
dispatch_semaphore_signal(stopSemaphore);
|
|
949
|
-
|
|
950
|
-
// Session cleanup
|
|
951
|
-
if (self.session && [self.session isRunning]) {
|
|
952
|
-
[self.session stopRunning];
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
if (self.session && self.deviceInput && [self.session.inputs containsObject:self.deviceInput]) {
|
|
956
|
-
[self.session removeInput:self.deviceInput];
|
|
957
|
-
}
|
|
958
|
-
if (self.session && self.videoOutput && [self.session.outputs containsObject:self.videoOutput]) {
|
|
959
|
-
[self.session removeOutput:self.videoOutput];
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
[self cleanupAfterStopOnQueue];
|
|
954
|
+
}
|
|
963
955
|
});
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
if (waitResult != 0) {
|
|
968
|
-
MRLog(@"⚠️ CameraRecorder: Stop did not finish within 5s (proceeding)");
|
|
969
|
-
} else {
|
|
970
|
-
MRLog(@"✅ CameraRecorder: Stop finalized");
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
self.stopSemaphore = nil;
|
|
974
|
-
self.isRecording = NO;
|
|
975
|
-
self.stopInFlight = NO;
|
|
976
|
-
return YES;
|
|
956
|
+
// A timeout does NOT release the stop-in-flight gate. A subsequent start
|
|
957
|
+
// must wait for this work queue to finish, especially after USB removal.
|
|
958
|
+
return [self waitForStopCompletion:5.0];
|
|
977
959
|
}
|
|
978
960
|
|
|
979
961
|
- (BOOL)waitForRecordingStartWithTimeout:(NSTimeInterval)timeout {
|
|
@@ -1022,6 +1004,15 @@ bool stopCameraRecording() {
|
|
|
1022
1004
|
}
|
|
1023
1005
|
}
|
|
1024
1006
|
|
|
1007
|
+
bool hasCameraRecordingResources() {
|
|
1008
|
+
CameraRecorder *recorder = [CameraRecorder sharedRecorder];
|
|
1009
|
+
return recorder.isRecording || recorder.stopInFlight || recorder.session != nil || recorder.writer != nil;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
bool isCameraRecordingStopping() {
|
|
1013
|
+
return [CameraRecorder sharedRecorder].stopInFlight;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1025
1016
|
bool isCameraRecording() {
|
|
1026
1017
|
return [CameraRecorder sharedRecorder].isRecording;
|
|
1027
1018
|
}
|