node-mac-recorder 2.24.10 → 2.24.12
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/binding.gyp +2 -1
- package/index-multiprocess.js +50 -6
- package/index.js +154 -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 +45 -62
- package/src/camera_recorder.mm +69 -78
- package/src/ios_device_recorder.mm +651 -78
- 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 +175 -195
- 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;
|
|
@@ -361,6 +365,7 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
361
365
|
|
|
362
366
|
// Start capture timer using target FPS
|
|
363
367
|
dispatch_queue_t captureQueue = dispatch_queue_create("AVFoundationCaptureQueue", DISPATCH_QUEUE_SERIAL);
|
|
368
|
+
g_avCaptureQueue = captureQueue;
|
|
364
369
|
g_avTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, captureQueue);
|
|
365
370
|
|
|
366
371
|
if (!g_avTimer) {
|
|
@@ -377,6 +382,7 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
377
382
|
|
|
378
383
|
dispatch_source_set_event_handler(g_avTimer, ^{
|
|
379
384
|
if (!g_avIsRecording) return;
|
|
385
|
+
if (MRSyncIsPaused()) return;
|
|
380
386
|
|
|
381
387
|
// Additional null checks for Electron safety
|
|
382
388
|
if (!localVideoInput || !localPixelBufferAdaptor) {
|
|
@@ -482,7 +488,8 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
482
488
|
if (!CMTIME_IS_VALID(relativeTime) || CMTIME_COMPARE_INLINE(relativeTime, <, kCMTimeZero)) {
|
|
483
489
|
relativeTime = kCMTimeZero;
|
|
484
490
|
}
|
|
485
|
-
CMTime presentationTime =
|
|
491
|
+
CMTime presentationTime = MRSyncAdjustForPauses(
|
|
492
|
+
CMTimeMakeWithSeconds(CMTimeGetSeconds(relativeTime), 600));
|
|
486
493
|
|
|
487
494
|
double stopLimit = MRSyncGetStopLimitSeconds();
|
|
488
495
|
if (stopLimit > 0) {
|
|
@@ -541,75 +548,51 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
541
548
|
}
|
|
542
549
|
|
|
543
550
|
extern "C" bool stopAVFoundationRecording() {
|
|
544
|
-
if (
|
|
545
|
-
return true;
|
|
546
|
-
}
|
|
547
|
-
|
|
551
|
+
if (g_avIsStopping.exchange(true)) return false;
|
|
548
552
|
g_avIsRecording = false;
|
|
549
|
-
|
|
550
|
-
// Stop audio recording if active
|
|
551
|
-
if (g_avAudioRecorder) {
|
|
552
|
-
MRLog(@"🛑 Stopping audio recording");
|
|
553
|
-
@try {
|
|
554
|
-
stopNativeAudioRecording(g_avAudioRecorder);
|
|
555
|
-
destroyNativeAudioRecorder(g_avAudioRecorder);
|
|
556
|
-
} @catch (NSException *exception) {
|
|
557
|
-
NSLog(@"⚠️ Exception while stopping audio: %@", exception.reason);
|
|
558
|
-
}
|
|
559
|
-
g_avAudioRecorder = nil;
|
|
560
|
-
g_avAudioOutputPath = nil;
|
|
561
|
-
MRLog(@"✅ Audio recording stopped");
|
|
562
|
-
}
|
|
563
|
-
|
|
553
|
+
BOOL finished = YES;
|
|
564
554
|
@try {
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
MRLog(@"✅ AVFoundation timer stopped safely");
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// Finish writing with null checks
|
|
583
|
-
AVAssetWriterInput *writerInput = g_avVideoInput;
|
|
584
|
-
if (writerInput) {
|
|
585
|
-
[writerInput markAsFinished];
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
AVAssetWriter *writer = g_avWriter;
|
|
589
|
-
if (writer && writer.status == AVAssetWriterStatusWriting) {
|
|
590
|
-
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
|
591
|
-
[writer finishWritingWithCompletionHandler:^{
|
|
592
|
-
dispatch_semaphore_signal(semaphore);
|
|
593
|
-
}];
|
|
594
|
-
// Add timeout to prevent infinite wait in Electron
|
|
595
|
-
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC);
|
|
596
|
-
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;
|
|
597
569
|
}
|
|
598
|
-
|
|
599
|
-
|
|
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];
|
|
600
576
|
g_avWriter = nil;
|
|
601
577
|
g_avVideoInput = nil;
|
|
602
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;
|
|
603
583
|
g_avFrameNumber = 0;
|
|
604
584
|
g_avStartTime = kCMTimeInvalid;
|
|
605
|
-
|
|
606
|
-
MRLog(@"✅ AVFoundation recording stopped");
|
|
607
|
-
return true;
|
|
608
|
-
|
|
609
|
-
} @catch (NSException *exception) {
|
|
610
|
-
NSLog(@"❌ Exception stopping AVFoundation recording: %@", exception.reason);
|
|
611
|
-
return false;
|
|
585
|
+
g_avIsStopping = false;
|
|
612
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;
|
|
613
596
|
}
|
|
614
597
|
|
|
615
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
|
}
|