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.
@@ -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;
@@ -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: qualityHint,
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
- g_avVideoInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings];
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 = CMTimeMakeWithSeconds(CMTimeGetSeconds(relativeTime), 600);
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 (!g_avIsRecording) {
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
- // Stop timer with Electron-safe cleanup
515
- if (g_avTimer) {
516
- // Mark as not recording FIRST to stop timer callbacks
517
- g_avIsRecording = false;
518
-
519
- // Cancel timer and wait a brief moment for completion
520
- dispatch_source_cancel(g_avTimer);
521
-
522
- // Use async to avoid deadlock in Electron
523
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{
524
- // Timer should be fully cancelled by now
525
- });
526
-
527
- g_avTimer = nil;
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
- // 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];
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() {
@@ -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
  }