node-mac-recorder 2.24.10 → 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.
@@ -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
- // CRITICAL FIX: Check if writer exists before trying to finish it
273
- if (self.writer) {
274
- // Only mark as finished if writerInput exists
275
- if (self.writerInput) {
276
- [self.writerInput markAsFinished];
277
- }
278
-
279
- __block BOOL finished = NO;
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(CMTimeSubtract(pts, self.startTime));
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
- g_lastStandaloneAudioOutputPath = outputPath;
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 = false;
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 = CMTimeMakeWithSeconds(CMTimeGetSeconds(relativeTime), 600);
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 (!g_avIsRecording) {
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
- // Stop timer with Electron-safe cleanup
566
- if (g_avTimer) {
567
- // Mark as not recording FIRST to stop timer callbacks
568
- g_avIsRecording = false;
569
-
570
- // Cancel timer and wait a brief moment for completion
571
- dispatch_source_cancel(g_avTimer);
572
-
573
- // Use async to avoid deadlock in Electron
574
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{
575
- // Timer should be fully cancelled by now
576
- });
577
-
578
- g_avTimer = nil;
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
- // Cleanup
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() {
@@ -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) ? (CFAbsoluteTimeGetCurrent() - 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
- [self performStartWithDeviceId:deviceId outputPath:outputPath token:token];
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
- BOOL hasActiveSession = (self.session && [self.session isRunning]);
895
- BOOL writerActive = (self.writer && self.writerStarted);
896
- if (!self.isRecording && !hasActiveSession && !writerActive) {
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
- // Stop video delegate
912
- if (self.videoOutput) {
913
- [self.videoOutput setSampleBufferDelegate:nil queue:nil];
914
- }
915
-
916
- // Finalize writer (audio_recorder.mm pattern)
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
- if (self.writerInput) {
927
- [self.writerInput markAsFinished];
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
- dispatch_semaphore_t writerSemaphore = dispatch_semaphore_create(0);
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
- dispatch_time_t waitTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC));
966
- long waitResult = dispatch_semaphore_wait(stopSemaphore, waitTime);
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
  }