node-mac-recorder 2.24.16 → 2.24.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.24.16",
3
+ "version": "2.24.17",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -7,6 +7,34 @@
7
7
  static dispatch_queue_t g_audioCaptureQueue = nil;
8
8
  static NSString *g_lastStandaloneAudioOutputPath = nil;
9
9
 
10
+ // A real PCM prefix survives readers/exporters that normalize track timestamps
11
+ // and ignore MOV empty edits. Allocation is bounded even for a bad device PTS.
12
+ static CMSampleBufferRef MRCreateSilentAudioPrefix(CMSampleBufferRef sample, double seconds) {
13
+ CMAudioFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sample);
14
+ const AudioStreamBasicDescription *asbd = format ? CMAudioFormatDescriptionGetStreamBasicDescription(format) : NULL;
15
+ if (!asbd || asbd->mFormatID != kAudioFormatLinearPCM ||
16
+ !isfinite(seconds) || seconds <= 0 || seconds > 30 ||
17
+ !isfinite(asbd->mSampleRate) || asbd->mSampleRate <= 0 ||
18
+ asbd->mBytesPerFrame == 0) return NULL;
19
+ double frameCount = floor(seconds * asbd->mSampleRate);
20
+ double byteCount = frameCount * asbd->mBytesPerFrame;
21
+ if (frameCount < 1 || byteCount > 32 * 1024 * 1024) return NULL;
22
+ CMBlockBufferRef block = NULL;
23
+ OSStatus status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault, NULL, (size_t)byteCount,
24
+ kCFAllocatorDefault, NULL, 0, (size_t)byteCount, 0, &block);
25
+ if (status != noErr || !block) return NULL;
26
+ status = CMBlockBufferFillDataBytes(0, block, 0, (size_t)byteCount);
27
+ CMSampleBufferRef silence = NULL;
28
+ if (status == noErr) {
29
+ CMSampleTimingInfo timing = { CMTimeMake(1, (int32_t)asbd->mSampleRate), kCMTimeZero, kCMTimeInvalid };
30
+ size_t frameSize = asbd->mBytesPerFrame;
31
+ CMSampleBufferCreateReady(kCFAllocatorDefault, block, format, (CMItemCount)frameCount,
32
+ 1, &timing, 1, &frameSize, &silence);
33
+ }
34
+ CFRelease(block);
35
+ return silence;
36
+ }
37
+
10
38
  @interface NativeAudioRecorder : NSObject<AVCaptureAudioDataOutputSampleBufferDelegate>
11
39
 
12
40
  @property (nonatomic, strong) AVAssetWriter *writer;
@@ -14,6 +42,7 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
14
42
  @property (nonatomic, strong) AVCaptureSession *session;
15
43
  @property (nonatomic, strong) AVCaptureAudioDataOutput *audioOutput;
16
44
  @property (nonatomic, assign) BOOL writerStarted;
45
+ @property (nonatomic, assign) BOOL primaryPrefixWritten;
17
46
  @property (nonatomic, assign) CMTime startTime;
18
47
  @property (nonatomic, strong) NSString *outputPath;
19
48
 
@@ -78,8 +107,10 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
78
107
  AVFileType requestedFileType = AVFileTypeQuickTimeMovie;
79
108
  BOOL requestedWebM = NO;
80
109
  if (@available(macOS 15.0, *)) {
81
- requestedFileType = @"public.webm";
82
- requestedWebM = YES;
110
+ if (!MRSyncUsesPrimaryTimeline()) {
111
+ requestedFileType = @"public.webm";
112
+ requestedWebM = YES;
113
+ }
83
114
  }
84
115
 
85
116
  @try {
@@ -281,7 +312,8 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
281
312
  @catch (NSException *exception) { NSLog(@"[Recorder] Microphone delegate detach: %@", exception.reason); }
282
313
  [outputToStop release];
283
314
  if (g_audioCaptureQueue) dispatch_sync(g_audioCaptureQueue, ^{});
284
- MRFinishAssetWriterSafely(self.writer, 8.0);
315
+ MRFinishAssetWriterSafely(self.writer, 8.0,
316
+ MRSyncUsesPrimaryTimeline() ? MRSyncGetStopLimitSeconds() : -1.0);
285
317
 
286
318
  self.writer = nil;
287
319
  self.writerInput = nil;
@@ -298,7 +330,8 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
298
330
  - (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
299
331
  if (!self.session || output != self.audioOutput) return;
300
332
  @try {
301
- if (MRSyncIsPaused()) {
333
+ BOOL primaryTimeline = MRSyncUsesPrimaryTimeline();
334
+ if (!primaryTimeline && MRSyncIsPaused()) {
302
335
  return;
303
336
  }
304
337
  if (!CMSampleBufferDataIsReady(sampleBuffer)) {
@@ -318,6 +351,11 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
318
351
  }
319
352
 
320
353
  CMTime timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
354
+ CMClockRef captureClock = self.session.masterClock;
355
+ if (primaryTimeline) {
356
+ timestamp = MRSyncHostTimestamp(timestamp, captureClock);
357
+ if (!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(timestamp))) return;
358
+ }
321
359
 
322
360
  // Keep microphone warm-up outside the recording until the USB iPhone movie
323
361
  // output has actually started writing its first frame.
@@ -327,7 +365,7 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
327
365
 
328
366
  // A/V SYNC: Hold audio samples until camera produces first frame
329
367
  // This ensures both audio and camera files start from the same wall-clock moment
330
- if (MRSyncShouldHoldAudioSample(timestamp)) {
368
+ if (!primaryTimeline && MRSyncShouldHoldAudioSample(timestamp)) {
331
369
  return;
332
370
  }
333
371
 
@@ -340,12 +378,25 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
340
378
  }
341
379
  [self.writer startSessionAtSourceTime:kCMTimeZero];
342
380
  self.writerStarted = YES;
343
- self.startTime = timestamp;
381
+ self.primaryPrefixWritten = NO;
382
+ self.startTime = primaryTimeline ? MRSyncPrimaryStartTimestamp() : timestamp;
344
383
  }
345
384
 
346
385
  if (!self.writerInput.readyForMoreMediaData) {
347
386
  return;
348
387
  }
388
+
389
+ if (primaryTimeline && !self.primaryPrefixWritten) {
390
+ double leadingSeconds = CMTimeGetSeconds(MRSyncPrimaryMediaTime(timestamp));
391
+ CMSampleBufferRef silence = MRCreateSilentAudioPrefix(sampleBuffer, leadingSeconds);
392
+ if (silence) {
393
+ BOOL appended = [self.writerInput appendSampleBuffer:silence];
394
+ CFRelease(silence);
395
+ if (!appended) return;
396
+ }
397
+ self.primaryPrefixWritten = YES;
398
+ if (!self.writerInput.readyForMoreMediaData) return;
399
+ }
349
400
 
350
401
  if (CMTIME_IS_INVALID(self.startTime)) {
351
402
  self.startTime = timestamp;
@@ -372,7 +423,10 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
372
423
  if (CMTIME_COMPARE_INLINE(adjustedPTS, <, kCMTimeZero)) {
373
424
  adjustedPTS = kCMTimeZero;
374
425
  }
375
- adjustedPTS = MRSyncAdjustForPauses(adjustedPTS);
426
+ adjustedPTS = primaryTimeline
427
+ ? MRSyncPrimaryMediaTime(MRSyncHostTimestamp(timingInfo[i].presentationTimeStamp, captureClock))
428
+ : MRSyncAdjustForPauses(adjustedPTS);
429
+ if (!CMTIME_IS_NUMERIC(adjustedPTS)) shouldDropBuffer = YES;
376
430
  timingInfo[i].presentationTimeStamp = adjustedPTS;
377
431
 
378
432
  if (stopLimit > 0) {
@@ -392,7 +446,9 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
392
446
  if (CMTIME_COMPARE_INLINE(adjustedDTS, <, kCMTimeZero)) {
393
447
  adjustedDTS = kCMTimeZero;
394
448
  }
395
- timingInfo[i].decodeTimeStamp = MRSyncAdjustForPauses(adjustedDTS);
449
+ timingInfo[i].decodeTimeStamp = primaryTimeline
450
+ ? MRSyncPrimaryMediaTime(MRSyncHostTimestamp(timingInfo[i].decodeTimeStamp, captureClock))
451
+ : MRSyncAdjustForPauses(adjustedDTS);
396
452
  }
397
453
  }
398
454
 
@@ -412,6 +468,9 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
412
468
  }
413
469
  }
414
470
 
471
+ // Never feed absolute device-clock timestamps into a zero-based iPhone
472
+ // writer if allocation or retiming failed.
473
+ if (primaryTimeline && bufferToAppend == sampleBuffer) shouldDropBuffer = YES;
415
474
  if (stopLimit > 0 && !shouldDropBuffer && bufferToAppend == sampleBuffer) {
416
475
  // No timing info available; approximate using buffer timestamp.
417
476
  CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
@@ -111,6 +111,7 @@ static void MRCameraRemoveFileIfExists(NSString *path) {
111
111
  @property (nonatomic, strong) AVAssetWriterInputPixelBufferAdaptor *pixelBufferAdaptor;
112
112
  @property (nonatomic, assign) CMTime startTime;
113
113
  @property (nonatomic, assign) BOOL writerStarted;
114
+ @property (nonatomic, assign) BOOL primaryPrefixWritten;
114
115
  @property (nonatomic, copy) NSString *outputPath;
115
116
  @property (nonatomic, copy) NSString *lastFinishedOutputPath;
116
117
 
@@ -510,7 +511,8 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
510
511
  if (self.stopInFlight || !self.isRecording || output != self.videoOutput) return;
511
512
  @try {
512
513
 
513
- if (MRSyncIsPaused()) {
514
+ BOOL primaryTimeline = MRSyncUsesPrimaryTimeline();
515
+ if (!primaryTimeline && MRSyncIsPaused()) {
514
516
  return;
515
517
  }
516
518
 
@@ -532,6 +534,12 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
532
534
  }
533
535
 
534
536
  CMTime timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
537
+ CMTime primaryMediaTime = kCMTimeInvalid;
538
+ if (primaryTimeline) {
539
+ timestamp = MRSyncHostTimestamp(timestamp, self.session.masterClock);
540
+ primaryMediaTime = MRSyncPrimaryMediaTime(timestamp);
541
+ if (!CMTIME_IS_NUMERIC(primaryMediaTime)) return;
542
+ }
535
543
 
536
544
  // Drop camera warm-up frames until the primary source (USB iPhone screen)
537
545
  // has committed its first frame. This keeps all files on one t=0 boundary.
@@ -549,7 +557,7 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
549
557
  [self completeStart:YES token:self.activeToken];
550
558
 
551
559
  // Hold camera frames until we see audio so timelines stay aligned
552
- if (MRSyncShouldHoldVideoFrame(timestamp)) {
560
+ if (!primaryTimeline && MRSyncShouldHoldVideoFrame(timestamp)) {
553
561
  return;
554
562
  }
555
563
 
@@ -561,13 +569,16 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
561
569
  }
562
570
  [self.writer startSessionAtSourceTime:kCMTimeZero]; // CRITICAL: t=0 timeline
563
571
  self.writerStarted = YES;
572
+ self.primaryPrefixWritten = NO;
564
573
 
565
574
  // LIP SYNC FIX: Align camera startTime with audio's first timestamp for perfect lip sync
566
575
  // This ensures camera and audio start from the same reference point
567
576
  CMTime audioFirstTimestamp = MRSyncAudioFirstTimestamp();
568
577
  CMTime alignmentOffset = MRSyncVideoAlignmentOffset();
569
578
 
570
- if (CMTIME_IS_VALID(audioFirstTimestamp)) {
579
+ if (primaryTimeline) {
580
+ self.startTime = MRSyncPrimaryStartTimestamp();
581
+ } else if (CMTIME_IS_VALID(audioFirstTimestamp)) {
571
582
  // Use audio's first timestamp as reference - this is the key to lip sync
572
583
  self.startTime = audioFirstTimestamp;
573
584
  CMTime offset = CMTimeSubtract(timestamp, audioFirstTimestamp);
@@ -606,7 +617,7 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
606
617
  // This should not happen if sync is working correctly
607
618
  adjustedTimestamp = kCMTimeZero;
608
619
  }
609
- adjustedTimestamp = MRSyncAdjustForPauses(adjustedTimestamp);
620
+ adjustedTimestamp = primaryTimeline ? primaryMediaTime : MRSyncAdjustForPauses(adjustedTimestamp);
610
621
 
611
622
  // LIP SYNC FIX: Check stopLimit OR elapsed time to drop frames after recording duration
612
623
  // This prevents camera from recording longer than audio
@@ -650,6 +661,15 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
650
661
  return;
651
662
  }
652
663
 
664
+ // Keep a late camera's initial delay in the media itself. Some demuxers
665
+ // discard MOV empty edits and would otherwise pull the camera ahead of mic.
666
+ if (primaryTimeline && !self.primaryPrefixWritten) {
667
+ if (CMTimeCompare(adjustedTimestamp, kCMTimeZero) > 0 &&
668
+ ![self.pixelBufferAdaptor appendPixelBuffer:pixelBuffer withPresentationTime:kCMTimeZero]) return;
669
+ self.primaryPrefixWritten = YES;
670
+ if (!self.writerInput.readyForMoreMediaData) return;
671
+ }
672
+
653
673
  // Append to writer with normalized timestamp
654
674
  BOOL success = [self.pixelBufferAdaptor appendPixelBuffer:pixelBuffer
655
675
  withPresentationTime:adjustedTimestamp];
@@ -17,7 +17,7 @@ extern "C" bool isStandaloneAudioRecording(void);
17
17
  extern "C" bool pauseIOSDeviceRecording(void);
18
18
  extern "C" bool resumeIOSDeviceRecording(void);
19
19
 
20
- @interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate>
20
+ @interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate, AVCaptureFileOutputDelegate>
21
21
  @property(nonatomic, strong) AVCaptureSession *session;
22
22
  @property(nonatomic, strong) AVCaptureDeviceInput *deviceInput;
23
23
  @property(nonatomic, strong) AVCaptureMovieFileOutput *movieOutput;
@@ -27,6 +27,7 @@ extern "C" bool resumeIOSDeviceRecording(void);
27
27
  @property(atomic) BOOL finishCompleted;
28
28
  @property(atomic) BOOL startRequested;
29
29
  @property(atomic) BOOL stopRequested;
30
+ @property(atomic) BOOL segmentStartPending;
30
31
  @property(atomic, strong) NSError *finishError;
31
32
  @property(atomic) BOOL paused;
32
33
  @property(atomic) BOOL segmentStartCompleted;
@@ -42,7 +43,8 @@ extern "C" bool resumeIOSDeviceRecording(void);
42
43
  @property(nonatomic) BOOL captureMicrophone;
43
44
  @property(nonatomic, copy) NSString *cameraOutputPath;
44
45
  @property(nonatomic, copy) NSString *audioOutputPath;
45
- @property(nonatomic, strong) NSDate *primaryStartedAt;
46
+ @property(atomic) CMTime primaryStartHostTime;
47
+ @property(atomic) CMTime stopHostTime;
46
48
  @end
47
49
 
48
50
  static BOOL MRIOSHasProducedMedia(MRIOSDeviceRecorder *recorder) {
@@ -67,10 +69,6 @@ static void MRIOSMarkSegmentStarted(MRIOSDeviceRecorder *recorder,
67
69
  recorder.segmentStartCompleted = YES;
68
70
  if (!recorder.startCompleted) {
69
71
  recorder.startCompleted = YES;
70
- recorder.primaryStartedAt = [NSDate date];
71
- if (!recorder.stopRequested) {
72
- MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
73
- }
74
72
  }
75
73
  if (!confirmedByDelegate) {
76
74
  MRLog(@"✅ iPhone capture start confirmed from recorded media progress");
@@ -79,6 +77,36 @@ static void MRIOSMarkSegmentStarted(MRIOSDeviceRecorder *recorder,
79
77
 
80
78
  @implementation MRIOSDeviceRecorder
81
79
 
80
+ - (BOOL)captureOutputShouldProvideSampleAccurateRecordingStart:(AVCaptureOutput *)output {
81
+ return YES;
82
+ }
83
+
84
+ - (void)captureOutput:(AVCaptureFileOutput *)output
85
+ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
86
+ fromConnection:(AVCaptureConnection *)connection {
87
+ if (!self.segmentStartPending || !CMSampleBufferDataIsReady(sampleBuffer)) return;
88
+ @synchronized (self) {
89
+ if (!self.segmentStartPending || self.stopRequested) return;
90
+ CMTime hostTime = MRSyncHostTimestamp(
91
+ CMSampleBufferGetPresentationTimeStamp(sampleBuffer), self.session.masterClock);
92
+ if (!CMTIME_IS_NUMERIC(hostTime)) return;
93
+ self.segmentStartPending = NO;
94
+ @try {
95
+ // macOS guarantees that a start requested inside this delegate
96
+ // includes this exact sample. The later didStart/progress signal
97
+ // confirms success but must never redefine the media's origin.
98
+ [output startRecordingToOutputFileURL:[NSURL fileURLWithPath:self.currentSegmentPath]
99
+ recordingDelegate:self];
100
+ self.primaryStartHostTime = hostTime;
101
+ MRSyncMarkPrimaryStarted(hostTime);
102
+ } @catch (NSException *exception) {
103
+ self.segmentFinishError = [NSError errorWithDomain:@"MacRecorderIOS" code:18
104
+ userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: @"iPhone sample start failed"}];
105
+ self.segmentFinishCompleted = YES;
106
+ }
107
+ }
108
+ }
109
+
82
110
  - (void)captureOutput:(AVCaptureFileOutput *)captureOutput
83
111
  didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
84
112
  fromConnections:(NSArray<AVCaptureConnection *> *)connections {
@@ -303,27 +331,27 @@ static BOOL MRIOSFileOutputSucceeded(NSError *error) {
303
331
  return [successfullyFinished boolValue];
304
332
  }
305
333
 
306
- static NSTimeInterval MRIOSRecordedDurationSeconds(MRIOSDeviceRecorder *recorder) {
334
+ static NSTimeInterval MRIOSRecordedDurationSeconds(MRIOSDeviceRecorder *recorder, CMTime hostTime) {
335
+ if (CMTIME_IS_NUMERIC(recorder.primaryStartHostTime)) {
336
+ return MAX(0.0, CMTimeGetSeconds(CMTimeSubtract(hostTime, recorder.primaryStartHostTime)));
337
+ }
307
338
  CMTime duration = recorder.movieOutput.recordedDuration;
308
339
  if (CMTIME_IS_NUMERIC(duration) &&
309
340
  CMTIME_COMPARE_INLINE(duration, >=, kCMTimeZero)) {
310
341
  return MAX(0.0, CMTimeGetSeconds(duration));
311
342
  }
312
- if (recorder.primaryStartedAt) {
313
- return MAX(0.0, -[recorder.primaryStartedAt timeIntervalSinceNow]);
314
- }
315
343
  return 0.0;
316
344
  }
317
345
 
318
- static void MRIOSBeginPauseRange(MRIOSDeviceRecorder *recorder) {
319
- recorder.pauseStartedAtSeconds = MRIOSRecordedDurationSeconds(recorder);
346
+ static void MRIOSBeginPauseRange(MRIOSDeviceRecorder *recorder, CMTime hostTime) {
347
+ recorder.pauseStartedAtSeconds = MRIOSRecordedDurationSeconds(recorder, hostTime);
320
348
  MRLog(@"⏸️ iPhone capture pause marker at %.3f seconds",
321
349
  recorder.pauseStartedAtSeconds);
322
350
  }
323
351
 
324
- static void MRIOSEndPauseRange(MRIOSDeviceRecorder *recorder) {
352
+ static void MRIOSEndPauseRange(MRIOSDeviceRecorder *recorder, CMTime hostTime) {
325
353
  if (recorder.pauseStartedAtSeconds < 0.0) return;
326
- NSTimeInterval end = MRIOSRecordedDurationSeconds(recorder);
354
+ NSTimeInterval end = MRIOSRecordedDurationSeconds(recorder, hostTime);
327
355
  NSTimeInterval start = recorder.pauseStartedAtSeconds;
328
356
  recorder.pauseStartedAtSeconds = -1.0;
329
357
  if (end <= start) return;
@@ -358,8 +386,7 @@ static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **error
358
386
  recorder.segmentFinishError = nil;
359
387
  recorder.recording = NO;
360
388
 
361
- [recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:segmentPath]
362
- recordingDelegate:recorder];
389
+ recorder.segmentStartPending = YES;
363
390
  // AVCaptureMovieFileOutput can begin writing before its delegate callback
364
391
  // is delivered. That callback may be queued behind Electron's synchronous
365
392
  // native call, so requiring only the callback creates a false timeout even
@@ -391,7 +418,7 @@ static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **error
391
418
  (unsigned long)recorder.movieOutput.connections.count);
392
419
  if (errorOut) {
393
420
  BOOL connectedButNoFrames = recorder.session.isRunning &&
394
- recorder.movieOutput.isRecording && durationSeconds <= 0.0;
421
+ (recorder.segmentStartPending || recorder.movieOutput.isRecording) && durationSeconds <= 0.0;
395
422
  *errorOut = recorder.segmentFinishError ?: (connectedButNoFrames
396
423
  ? MRIOSNoFramesError()
397
424
  : [NSError errorWithDomain:@"MacRecorderIOS"
@@ -405,9 +432,10 @@ static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **error
405
432
  }
406
433
 
407
434
  static BOOL MRIOSStopCurrentSegment(MRIOSDeviceRecorder *recorder) {
408
- if (recorder.movieOutput.isRecording) {
409
- [recorder.movieOutput stopRecording];
435
+ @synchronized (recorder) {
436
+ recorder.segmentStartPending = NO;
410
437
  }
438
+ if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
411
439
  if (recorder.segmentStartCompleted && !recorder.segmentFinishCompleted) {
412
440
  if (!MRWaitForFlag(^bool{ return recorder.segmentFinishCompleted; }, 20.0)) {
413
441
  MRLog(@"⚠️ iPhone segment is still finalizing");
@@ -692,7 +720,9 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
692
720
  recorder.captureMicrophone = captureMicrophone;
693
721
  recorder.cameraOutputPath = cameraOutputPath;
694
722
  recorder.audioOutputPath = audioOutputPath;
695
- recorder.primaryStartedAt = nil;
723
+ recorder.primaryStartHostTime = kCMTimeInvalid;
724
+ recorder.stopHostTime = kCMTimeInvalid;
725
+ recorder.movieOutput.delegate = recorder;
696
726
 
697
727
  g_iosRecorder = recorder;
698
728
  [recorder.session beginConfiguration];
@@ -728,12 +758,11 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
728
758
  recorder.movieOutput.movieFragmentInterval = CMTimeMakeWithSeconds(2.0, 600);
729
759
  g_iosRecorder = recorder;
730
760
 
731
- // Camera and microphone sessions are prepared first but discard their
732
- // warm-up samples until AVCaptureMovieFileOutput confirms the iPhone's
733
- // first frame. Every produced file therefore starts at the same t=0.
761
+ // Use the sample-accurate USB origin for every file, including a camera
762
+ // or microphone whose first callback arrives later.
734
763
  MRSyncConfigure(captureMicrophone);
735
764
  MRSyncConfigureCamera(captureCamera);
736
- MRSyncConfigurePrimaryStart(captureCamera || captureMicrophone);
765
+ MRSyncConfigurePrimaryStart(YES);
737
766
 
738
767
  if (captureCamera) {
739
768
  NSError *cameraError = nil;
@@ -838,15 +867,24 @@ extern "C" bool stopIOSDeviceRecording(void) {
838
867
  MRIOSDeviceRecorder *recorder = g_iosRecorder;
839
868
  if (!recorder) return true;
840
869
 
841
- recorder.stopRequested = YES;
842
- if (recorder.primaryStartedAt) {
843
- NSTimeInterval duration = MAX(0.0,
844
- -[recorder.primaryStartedAt timeIntervalSinceNow] - MRSyncGetPausedDurationSeconds());
845
- MRSyncSetStopLimitSeconds(duration);
870
+ CMTime stopHostTime;
871
+ @synchronized (recorder) {
872
+ if (!CMTIME_IS_NUMERIC(recorder.stopHostTime)) {
873
+ recorder.stopHostTime = CMClockGetTime(CMClockGetHostTimeClock());
874
+ }
875
+ stopHostTime = recorder.stopHostTime;
876
+ recorder.stopRequested = YES;
877
+ recorder.segmentStartPending = NO;
846
878
  }
879
+ // Request the USB stop before waiting for either auxiliary writer.
880
+ // Do not hold the delegate lock across an AVFoundation stop call.
881
+ if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
847
882
  if (recorder.paused) {
848
- MRIOSEndPauseRange(recorder);
883
+ MRIOSEndPauseRange(recorder, stopHostTime);
884
+ MRSyncResumeAtHostTime(stopHostTime);
849
885
  }
886
+ CMTime mediaStopTime = MRSyncPrimaryMediaTime(stopHostTime);
887
+ if (CMTIME_IS_NUMERIC(mediaStopTime)) MRSyncSetStopLimitSeconds(CMTimeGetSeconds(mediaStopTime));
850
888
 
851
889
  BOOL cameraStopped = YES;
852
890
  BOOL microphoneStopped = YES;
@@ -894,6 +932,7 @@ extern "C" bool stopIOSDeviceRecording(void) {
894
932
  } @catch (NSException *exception) {
895
933
  MRLog(@"⚠️ iPhone capture teardown warning: %@", exception.reason);
896
934
  }
935
+ recorder.movieOutput.delegate = nil;
897
936
  recorder.movieOutput = nil;
898
937
  recorder.deviceInput = nil;
899
938
  recorder.session = nil;
@@ -906,7 +945,7 @@ extern "C" bool stopIOSDeviceRecording(void) {
906
945
  recorder.outputPath = nil;
907
946
  recorder.cameraOutputPath = nil;
908
947
  recorder.audioOutputPath = nil;
909
- recorder.primaryStartedAt = nil;
948
+ recorder.primaryStartHostTime = kCMTimeInvalid;
910
949
  g_iosRecorder = nil;
911
950
  [recorder release];
912
951
 
@@ -948,12 +987,13 @@ extern "C" bool pauseIOSDeviceRecording(void) {
948
987
  // Do not stop or pause AVCaptureMovieFileOutput here. USB iPhone
949
988
  // muxed sources may fail to re-arm their compressor on resume.
950
989
  // Capture continuously and trim this time range during finalization.
951
- MRIOSBeginPauseRange(recorder);
952
- MRSyncPause();
990
+ CMTime pauseHostTime = CMClockGetTime(CMClockGetHostTimeClock());
991
+ MRIOSBeginPauseRange(recorder, pauseHostTime);
992
+ MRSyncPauseAtHostTime(pauseHostTime);
953
993
  recorder.paused = YES;
954
994
  return true;
955
995
  } @catch (NSException *exception) {
956
- MRSyncResume();
996
+ MRSyncResumeAtHostTime(CMClockGetTime(CMClockGetHostTimeClock()));
957
997
  MRLog(@"❌ iPhone pause failed: %@", exception.reason);
958
998
  return false;
959
999
  }
@@ -967,9 +1007,10 @@ extern "C" bool resumeIOSDeviceRecording(void) {
967
1007
  if (!recorder.paused) return recorder.movieOutput.isRecording;
968
1008
  if (!recorder.movieOutput.isRecording) return false;
969
1009
  @try {
970
- MRIOSEndPauseRange(recorder);
1010
+ CMTime resumeHostTime = CMClockGetTime(CMClockGetHostTimeClock());
1011
+ MRIOSEndPauseRange(recorder, resumeHostTime);
971
1012
  recorder.paused = NO;
972
- MRSyncResume();
1013
+ MRSyncResumeAtHostTime(resumeHostTime);
973
1014
  return true;
974
1015
  } @catch (NSException *exception) {
975
1016
  MRLog(@"❌ iPhone resume failed: %@", exception.reason);
@@ -40,6 +40,16 @@ void MRSyncConfigurePrimaryStart(BOOL expectPrimary);
40
40
  void MRSyncMarkPrimaryStarted(CMTime timestamp);
41
41
  BOOL MRSyncShouldHoldForPrimary(CMTime timestamp);
42
42
 
43
+ // iPhone-only timeline. Convert each capture session's PTS to the host clock
44
+ // before comparing sources; preserve late arrivals instead of rebasing each
45
+ // file to its own first sample. Invalid media time means discard the sample.
46
+ BOOL MRSyncUsesPrimaryTimeline(void);
47
+ CMTime MRSyncPrimaryStartTimestamp(void);
48
+ CMTime MRSyncHostTimestamp(CMTime timestamp, CMClockRef captureClock);
49
+ CMTime MRSyncPrimaryMediaTime(CMTime hostTimestamp);
50
+ void MRSyncPauseAtHostTime(CMTime timestamp);
51
+ void MRSyncResumeAtHostTime(CMTime timestamp);
52
+
43
53
  // Optional hard stop limit (seconds) shared across capture components.
44
54
  void MRSyncSetStopLimitSeconds(double seconds);
45
55
  double MRSyncGetStopLimitSeconds(void);
@@ -1,5 +1,6 @@
1
1
  #import "sync_timeline.h"
2
2
  #import "logging.h"
3
+ #include <vector>
3
4
 
4
5
  static dispatch_queue_t MRSyncQueue() {
5
6
  static dispatch_once_t onceToken;
@@ -34,6 +35,8 @@ static BOOL g_primaryReady = YES;
34
35
  static CMTime g_primaryStartTimestamp = kCMTimeInvalid;
35
36
  static CMTime g_primaryHoldFirstTimestamp = kCMTimeInvalid;
36
37
  static BOOL g_primaryHoldLogged = NO;
38
+ struct MRPrimaryPauseRange { CMTime start; CMTime end; };
39
+ static std::vector<MRPrimaryPauseRange> g_primaryPauses;
37
40
 
38
41
  void MRSyncConfigure(BOOL expectAudio) {
39
42
  dispatch_sync(MRSyncQueue(), ^{
@@ -58,6 +61,7 @@ void MRSyncConfigure(BOOL expectAudio) {
58
61
  g_primaryStartTimestamp = kCMTimeInvalid;
59
62
  g_primaryHoldFirstTimestamp = kCMTimeInvalid;
60
63
  g_primaryHoldLogged = NO;
64
+ g_primaryPauses.clear();
61
65
  });
62
66
  }
63
67
 
@@ -94,7 +98,10 @@ double MRSyncGetPausedDurationSeconds(void) {
94
98
  __block double duration = 0;
95
99
  dispatch_sync(MRSyncQueue(), ^{
96
100
  duration = g_totalPausedSeconds;
97
- if (g_isPaused && g_pauseStartedAt > 0) {
101
+ if (g_expectPrimary && g_isPaused && !g_primaryPauses.empty()) {
102
+ duration += MAX(0, CMTimeGetSeconds(CMTimeSubtract(
103
+ CMClockGetTime(CMClockGetHostTimeClock()), g_primaryPauses.back().start)));
104
+ } else if (g_isPaused && g_pauseStartedAt > 0) {
98
105
  duration += MAX(0, CFAbsoluteTimeGetCurrent() - g_pauseStartedAt);
99
106
  }
100
107
  });
@@ -318,12 +325,67 @@ void MRSyncConfigurePrimaryStart(BOOL expectPrimary) {
318
325
  g_primaryStartTimestamp = kCMTimeInvalid;
319
326
  g_primaryHoldFirstTimestamp = kCMTimeInvalid;
320
327
  g_primaryHoldLogged = NO;
328
+ g_primaryPauses.clear();
321
329
  });
322
330
  if (expectPrimary) {
323
331
  MRLog(@"🔄 A/V SYNC: Primary-source start barrier enabled");
324
332
  }
325
333
  }
326
334
 
335
+ BOOL MRSyncUsesPrimaryTimeline(void) {
336
+ __block BOOL enabled = NO;
337
+ dispatch_sync(MRSyncQueue(), ^{ enabled = g_expectPrimary; });
338
+ return enabled;
339
+ }
340
+
341
+ CMTime MRSyncPrimaryStartTimestamp(void) {
342
+ __block CMTime timestamp = kCMTimeInvalid;
343
+ dispatch_sync(MRSyncQueue(), ^{ timestamp = g_primaryStartTimestamp; });
344
+ return timestamp;
345
+ }
346
+
347
+ CMTime MRSyncHostTimestamp(CMTime timestamp, CMClockRef captureClock) {
348
+ if (!CMTIME_IS_NUMERIC(timestamp) || !captureClock) return kCMTimeInvalid;
349
+ return CMSyncConvertTime(timestamp, captureClock, CMClockGetHostTimeClock());
350
+ }
351
+
352
+ CMTime MRSyncPrimaryMediaTime(CMTime hostTimestamp) {
353
+ if (!CMTIME_IS_NUMERIC(hostTimestamp)) return kCMTimeInvalid;
354
+ __block CMTime result = kCMTimeInvalid;
355
+ dispatch_sync(MRSyncQueue(), ^{
356
+ if (!g_expectPrimary || !CMTIME_IS_NUMERIC(g_primaryStartTimestamp) ||
357
+ CMTimeCompare(hostTimestamp, g_primaryStartTimestamp) < 0) return;
358
+ CMTime paused = kCMTimeZero;
359
+ for (const auto &range : g_primaryPauses) {
360
+ if (CMTimeCompare(hostTimestamp, range.start) < 0) break;
361
+ if (!CMTIME_IS_NUMERIC(range.end) || CMTimeCompare(hostTimestamp, range.end) < 0) return;
362
+ paused = CMTimeAdd(paused, CMTimeSubtract(range.end, range.start));
363
+ }
364
+ result = CMTimeSubtract(CMTimeSubtract(hostTimestamp, g_primaryStartTimestamp), paused);
365
+ });
366
+ return result;
367
+ }
368
+
369
+ void MRSyncPauseAtHostTime(CMTime timestamp) {
370
+ if (!CMTIME_IS_NUMERIC(timestamp)) return;
371
+ dispatch_sync(MRSyncQueue(), ^{
372
+ if (!g_expectPrimary || g_isPaused) return;
373
+ g_primaryPauses.push_back({timestamp, kCMTimeInvalid});
374
+ g_isPaused = YES;
375
+ });
376
+ }
377
+
378
+ void MRSyncResumeAtHostTime(CMTime timestamp) {
379
+ if (!CMTIME_IS_NUMERIC(timestamp)) return;
380
+ dispatch_sync(MRSyncQueue(), ^{
381
+ if (!g_expectPrimary || !g_isPaused || g_primaryPauses.empty()) return;
382
+ auto &range = g_primaryPauses.back();
383
+ range.end = CMTimeMaximum(timestamp, range.start);
384
+ g_totalPausedSeconds += CMTimeGetSeconds(CMTimeSubtract(range.end, range.start));
385
+ g_isPaused = NO;
386
+ });
387
+ }
388
+
327
389
  void MRSyncMarkPrimaryStarted(CMTime timestamp) {
328
390
  if (!CMTIME_IS_VALID(timestamp)) return;
329
391
 
@@ -0,0 +1,249 @@
1
+ // Exercise the production delegates and MOV writers without opening devices.
2
+ #import "../src/sync_timeline.mm"
3
+ #import "../src/audio_recorder.mm"
4
+ #import "../src/camera_recorder.mm"
5
+ #import "../src/ios_device_recorder.mm"
6
+ #include <unistd.h>
7
+
8
+ @interface MRProbeSession : AVCaptureSession
9
+ @property(nonatomic, assign) CMClockRef probeClock;
10
+ @end
11
+ @implementation MRProbeSession
12
+ - (CMClockRef)masterClock { return self.probeClock ?: CMClockGetHostTimeClock(); }
13
+ - (BOOL)isRunning { return NO; }
14
+ @end
15
+
16
+ @interface MRProbeMovieOutput : AVCaptureMovieFileOutput
17
+ @property NSUInteger starts;
18
+ @end
19
+ @implementation MRProbeMovieOutput
20
+ - (void)startRecordingToOutputFileURL:(NSURL *)url recordingDelegate:(id<AVCaptureFileOutputRecordingDelegate>)delegate {
21
+ self.starts += 1;
22
+ }
23
+ @end
24
+
25
+ static CMTime T(double seconds) { return CMTimeMakeWithSeconds(seconds, 48000); }
26
+
27
+ static CMSampleBufferRef Video(double seconds) {
28
+ CVPixelBufferRef pixels = NULL;
29
+ CVPixelBufferCreate(kCFAllocatorDefault, 64, 64, kCVPixelFormatType_32BGRA,
30
+ (CFDictionaryRef)@{(id)kCVPixelBufferIOSurfacePropertiesKey: @{}}, &pixels);
31
+ CVPixelBufferLockBaseAddress(pixels, 0);
32
+ memset(CVPixelBufferGetBaseAddress(pixels), 128, CVPixelBufferGetDataSize(pixels));
33
+ CVPixelBufferUnlockBaseAddress(pixels, 0);
34
+ CMVideoFormatDescriptionRef format = NULL;
35
+ CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixels, &format);
36
+ CMSampleTimingInfo timing = { T(1.0 / 30), T(seconds), kCMTimeInvalid };
37
+ CMSampleBufferRef sample = NULL;
38
+ CMSampleBufferCreateReadyWithImageBuffer(kCFAllocatorDefault, pixels, format, &timing, &sample);
39
+ CFRelease(format);
40
+ CFRelease(pixels);
41
+ return sample;
42
+ }
43
+
44
+ static CMSampleBufferRef Audio(double seconds) {
45
+ AudioStreamBasicDescription asbd = {};
46
+ asbd.mSampleRate = 48000;
47
+ asbd.mFormatID = kAudioFormatLinearPCM;
48
+ asbd.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
49
+ asbd.mBytesPerPacket = asbd.mBytesPerFrame = 2;
50
+ asbd.mFramesPerPacket = asbd.mChannelsPerFrame = 1;
51
+ asbd.mBitsPerChannel = 16;
52
+ CMAudioFormatDescriptionRef format = NULL;
53
+ CMAudioFormatDescriptionCreate(kCFAllocatorDefault, &asbd, 0, NULL, 0, NULL, NULL, &format);
54
+ CMBlockBufferRef block = NULL;
55
+ CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault, NULL, 1920,
56
+ kCFAllocatorDefault, NULL, 0, 1920, 0, &block);
57
+ CMBlockBufferFillDataBytes(0, block, 0, 1920);
58
+ char *data = NULL;
59
+ CMBlockBufferGetDataPointer(block, 0, NULL, NULL, &data);
60
+ for (int i = 0; i < 960; i++) ((int16_t *)data)[i] = (int16_t)(8000 * sin(i * 2 * M_PI / 48));
61
+ CMSampleTimingInfo timing = { T(1.0 / 48000), T(seconds), kCMTimeInvalid };
62
+ size_t size = 2;
63
+ CMSampleBufferRef sample = NULL;
64
+ CMSampleBufferCreateReady(kCFAllocatorDefault, block, format, 960, 1, &timing, 1, &size, &sample);
65
+ CFRelease(format);
66
+ CFRelease(block);
67
+ return sample;
68
+ }
69
+
70
+ static void WaitForInput(AVAssetWriterInput *input) {
71
+ for (int i = 0; input && !input.readyForMoreMediaData && i < 2000; i++) usleep(1000);
72
+ }
73
+
74
+ static Napi::Value Run(const Napi::CallbackInfo &info) {
75
+ @autoreleasepool {
76
+ NSString *directory = [NSString stringWithUTF8String:info[0].As<Napi::String>().Utf8Value().c_str()];
77
+ NSMutableArray *failures = [NSMutableArray array];
78
+ NSUInteger checks = 0;
79
+ auto check = [&](BOOL ok, NSString *message) { checks++; if (!ok) [failures addObject:message]; };
80
+ auto near = [&](CMTime actual, double expected, NSString *message) {
81
+ check(CMTIME_IS_NUMERIC(actual) && fabs(CMTimeGetSeconds(actual) - expected) < 0.0001, message);
82
+ };
83
+
84
+ MRSyncConfigure(YES);
85
+ MRSyncConfigureCamera(YES);
86
+ MRSyncConfigurePrimaryStart(YES);
87
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(100))), @"warmup must not enter a writer");
88
+ MRIOSDeviceRecorder *phone = [[[MRIOSDeviceRecorder alloc] init] autorelease];
89
+ phone.session = [[[MRProbeSession alloc] init] autorelease];
90
+ MRProbeMovieOutput *movie = [[[MRProbeMovieOutput alloc] init] autorelease];
91
+ phone.movieOutput = movie;
92
+ phone.currentSegmentPath = [directory stringByAppendingPathComponent:@"phone.mov"];
93
+ phone.primaryStartHostTime = kCMTimeInvalid;
94
+ phone.segmentStartPending = YES;
95
+ CMSampleBufferRef first = Video(100);
96
+ [phone captureOutput:movie didOutputSampleBuffer:first fromConnection:nil];
97
+ [phone captureOutput:movie didOutputSampleBuffer:first fromConnection:nil];
98
+ CFRelease(first);
99
+ check(movie.starts == 1, @"sample-accurate USB start must happen exactly once");
100
+ near(MRSyncPrimaryStartTimestamp(), 100, @"origin must come from the first USB sample");
101
+ MRIOSMarkSegmentStarted(phone, NO);
102
+ MRIOSMarkSegmentStarted(phone, YES);
103
+ near(MRSyncPrimaryStartTimestamp(), 100, @"delayed progress/delegate must not move the origin");
104
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(99.9))), @"pre-origin samples are discarded");
105
+ near(MRSyncPrimaryMediaTime(T(100.6)), 0.6, @"late camera keeps its actual offset");
106
+ check(!CMTIME_IS_NUMERIC(MRSyncHostTimestamp(T(100), NULL)), @"missing capture clock is rejected");
107
+
108
+ // Distinct session clock epochs must not become an A/V offset.
109
+ CMTimebaseRef cameraClock = NULL, audioClock = NULL;
110
+ CMTimebaseCreateWithSourceClock(kCFAllocatorDefault, CMClockGetHostTimeClock(), &cameraClock);
111
+ CMTimebaseCreateWithSourceClock(kCFAllocatorDefault, CMClockGetHostTimeClock(), &audioClock);
112
+ CMTimebaseSetTime(cameraClock, T(2000));
113
+ CMTimebaseSetRate(cameraClock, 1);
114
+ CMTimebaseSetTime(audioClock, T(500));
115
+ CMTimebaseSetRate(audioClock, 1);
116
+ MRProbeSession *cameraSession = [[[MRProbeSession alloc] init] autorelease];
117
+ cameraSession.probeClock = (CMClockRef)cameraClock;
118
+ MRProbeSession *audioSession = [[[MRProbeSession alloc] init] autorelease];
119
+ audioSession.probeClock = (CMClockRef)audioClock;
120
+
121
+ CameraRecorder *camera = [[[CameraRecorder alloc] init] autorelease];
122
+ camera.session = cameraSession;
123
+ camera.videoOutput = [[[AVCaptureVideoDataOutput alloc] init] autorelease];
124
+ camera.outputPath = [directory stringByAppendingPathComponent:@"camera.mov"];
125
+ camera.isRecording = YES;
126
+ NativeAudioRecorder *microphone = [[[NativeAudioRecorder alloc] init] autorelease];
127
+ microphone.session = audioSession;
128
+ microphone.audioOutput = [[[AVCaptureAudioDataOutput alloc] init] autorelease];
129
+ microphone.outputPath = [directory stringByAppendingPathComponent:@"microphone.mov"];
130
+ auto sendVideo = [&](double time) {
131
+ WaitForInput(camera.writerInput);
132
+ double sourceTime = CMTimeGetSeconds(CMSyncConvertTime(T(time), CMClockGetHostTimeClock(), cameraClock));
133
+ CMSampleBufferRef sample = Video(sourceTime);
134
+ [camera captureOutput:camera.videoOutput didOutputSampleBuffer:sample fromConnection:nil];
135
+ CFRelease(sample);
136
+ };
137
+ auto sendAudio = [&](double time) {
138
+ WaitForInput(microphone.writerInput);
139
+ double sourceTime = CMTimeGetSeconds(CMSyncConvertTime(T(time), CMClockGetHostTimeClock(), audioClock));
140
+ CMSampleBufferRef sample = Audio(sourceTime);
141
+ [microphone captureOutput:microphone.audioOutput didOutputSampleBuffer:sample fromConnection:nil];
142
+ CFRelease(sample);
143
+ };
144
+ // Deliberately start the mic 400 ms before the camera. Both must retain
145
+ // their offsets to the phone, without waiting for the other device.
146
+ for (int i = 0; i < 40; i++) sendAudio(100.2 + i * 0.02);
147
+ for (int i = 0; i < 12; i++) sendVideo(100.6 + i / 30.0);
148
+ near(microphone.startTime, 100, @"mic writer uses USB origin");
149
+ near(camera.startTime, 100, @"camera writer uses USB origin");
150
+ MRSyncPauseAtHostTime(T(101));
151
+ phone.pauseRanges = [NSMutableArray array];
152
+ MRIOSBeginPauseRange(phone, T(101));
153
+ near(MRSyncPrimaryMediaTime(T(100.99)), 0.99, @"buffer queued before pause retains its timestamp");
154
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(101.1))), @"paused samples are dropped");
155
+ sendAudio(102);
156
+ sendVideo(102);
157
+ MRSyncResumeAtHostTime(T(104));
158
+ MRIOSEndPauseRange(phone, T(104));
159
+ check(phone.pauseRanges.count == 1 &&
160
+ fabs(phone.pauseRanges[0][@"start"].doubleValue - 1) < 0.0001 &&
161
+ fabs(phone.pauseRanges[0][@"end"].doubleValue - 4) < 0.0001,
162
+ @"phone trims the exact same 1-to-4-second range as camera and microphone");
163
+ check(!CMTIME_IS_NUMERIC(MRSyncPrimaryMediaTime(T(102))), @"late delivery of a paused sample is still dropped");
164
+ near(MRSyncPrimaryMediaTime(T(104.5)), 1.5, @"resume removes exactly the phone's pause range");
165
+ for (int i = 0; i < 50; i++) sendAudio(104 + i * 0.02);
166
+ for (int i = 0; i < 30; i++) sendVideo(104 + i / 30.0);
167
+ MRSyncSetStopLimitSeconds(2);
168
+ check(MRFinishAssetWriterSafely(camera.writer, 5, 2), @"camera MOV finalizes");
169
+ check(MRFinishAssetWriterSafely(microphone.writer, 5, 2), @"microphone MOV finalizes");
170
+ for (NSString *kind in @[@"camera", @"microphone"]) {
171
+ NSURL *url = [NSURL fileURLWithPath:[directory stringByAppendingPathComponent:[kind stringByAppendingString:@".mov"]]];
172
+ AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
173
+ BOOL isCamera = [kind isEqualToString:@"camera"];
174
+ AVAssetTrack *track = [asset tracksWithMediaType:isCamera ? AVMediaTypeVideo : AVMediaTypeAudio].firstObject;
175
+ check(track != nil, [kind stringByAppendingString:@" track exists"]);
176
+ if (!track) continue;
177
+ check(fabs(CMTimeGetSeconds(asset.duration) - 2) < 0.04, [kind stringByAppendingString:@" has the shared stop boundary"]);
178
+ NSError *error = nil;
179
+ AVAssetReader *reader = [AVAssetReader assetReaderWithAsset:asset error:&error];
180
+ AVAssetReaderOutput *output = isCamera
181
+ ? [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:track outputSettings:nil]
182
+ : [AVAssetReaderAudioMixOutput assetReaderAudioMixOutputWithAudioTracks:@[track]
183
+ audioSettings:@{AVFormatIDKey: @(kAudioFormatLinearPCM), AVLinearPCMBitDepthKey: @16,
184
+ AVLinearPCMIsFloatKey: @NO, AVLinearPCMIsNonInterleaved: @NO}];
185
+ [reader addOutput:output];
186
+ [reader startReading];
187
+ CMTime previous = kCMTimeInvalid;
188
+ CMTime firstPTS = kCMTimeInvalid;
189
+ CMTime lastPTS = kCMTimeInvalid;
190
+ double firstSound = -1;
191
+ double secondVideoPTS = INFINITY;
192
+ NSUInteger count = 0;
193
+ while (CMSampleBufferRef sample = [output copyNextSampleBuffer]) {
194
+ CMTime pts = CMSampleBufferGetPresentationTimeStamp(sample);
195
+ if (CMSampleBufferGetTotalSampleSize(sample) == 0) { CFRelease(sample); continue; }
196
+ CMTime dts = CMSampleBufferGetDecodeTimeStamp(sample);
197
+ if (!CMTIME_IS_NUMERIC(dts)) dts = pts;
198
+ check(!CMTIME_IS_NUMERIC(previous) || CMTimeCompare(dts, previous) >= 0,
199
+ [kind stringByAppendingString:@" has monotonic decode timestamps after pause"]);
200
+ previous = dts;
201
+ firstPTS = CMTIME_IS_NUMERIC(firstPTS) ? CMTimeMinimum(firstPTS, pts) : pts;
202
+ lastPTS = CMTIME_IS_NUMERIC(lastPTS) ? CMTimeMaximum(lastPTS, pts) : pts;
203
+ if (isCamera && CMTimeGetSeconds(pts) > 0.001) secondVideoPTS = MIN(secondVideoPTS, CMTimeGetSeconds(pts));
204
+ if (!isCamera && firstSound < 0) {
205
+ CMBlockBufferRef block = CMSampleBufferGetDataBuffer(sample);
206
+ size_t length = CMBlockBufferGetDataLength(block);
207
+ std::vector<int16_t> pcm(length / sizeof(int16_t));
208
+ CMBlockBufferCopyDataBytes(block, 0, length, pcm.data());
209
+ for (size_t i = 0; i < pcm.size(); i++) {
210
+ if (abs(pcm[i]) > 500) { firstSound = CMTimeGetSeconds(pts) + i / 48000.0; break; }
211
+ }
212
+ }
213
+ count++;
214
+ CFRelease(sample);
215
+ }
216
+ check(fabs(CMTimeGetSeconds(firstPTS)) < 0.04,
217
+ [NSString stringWithFormat:@"%@ starts at zero even when a reader ignores empty edits (%.3f)", kind, CMTimeGetSeconds(firstPTS)]);
218
+ if (isCamera) check(fabs(secondVideoPTS - 0.6) < 0.04, @"camera holds its first frame until the actual capture time");
219
+ else check(fabs(firstSound - 0.2) < 0.04,
220
+ [NSString stringWithFormat:@"microphone leading silence preserves sound onset (%.3f)", firstSound]);
221
+ check(count > 1 && CMTimeGetSeconds(lastPTS) > 1.5,
222
+ [NSString stringWithFormat:@"%@ contains media on both sides of pause (count %lu, last PTS %.3f)", kind, count, CMTimeGetSeconds(lastPTS)]);
223
+ }
224
+ // Repeated pauses across a long session: no callback-time accumulation.
225
+ for (int i = 0; i < 100; i++) {
226
+ MRSyncPauseAtHostTime(T(110 + i * 10));
227
+ MRSyncResumeAtHostTime(T(113 + i * 10));
228
+ }
229
+ near(MRSyncPrimaryMediaTime(T(1110)), 707, @"100 pauses preserve the common long-recording timeline");
230
+ MRSyncConfigure(NO);
231
+ check(!MRSyncUsesPrimaryTimeline(), @"desktop recording does not inherit the iPhone timeline");
232
+ check(!MRSyncShouldHoldVideoFrame(T(200)), @"desktop camera barrier remains unchanged");
233
+ near(MRSyncAdjustForPauses(T(5)), 5, @"desktop pause duration resets between recordings");
234
+ cameraSession.probeClock = nil;
235
+ audioSession.probeClock = nil;
236
+ CFRelease(cameraClock);
237
+ CFRelease(audioClock);
238
+ Napi::Object result = Napi::Object::New(info.Env());
239
+ result.Set("checks", Napi::Number::New(info.Env(), checks));
240
+ result.Set("failures", Napi::String::New(info.Env(), [[failures componentsJoinedByString:@"\n"] UTF8String]));
241
+ return result;
242
+ }
243
+ }
244
+
245
+ static Napi::Object InitProbe(Napi::Env env, Napi::Object exports) {
246
+ exports.Set("run", Napi::Function::New(env, Run));
247
+ return exports;
248
+ }
249
+ NODE_API_MODULE(ios_sync_probe, InitProbe)
@@ -0,0 +1,30 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { execFileSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ test('iPhone, camera and microphone preserve one media timeline through delayed startup and pauses', {
9
+ skip: process.platform !== 'darwin', timeout: 120000,
10
+ }, (t) => {
11
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'recorder-ios-sync-'));
12
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
13
+ const headers = [
14
+ process.env.npm_config_nodedir && path.join(process.env.npm_config_nodedir, 'include/node'),
15
+ path.join(os.homedir(), 'Library/Caches/node-gyp', process.versions.node, 'include/node'),
16
+ path.resolve(path.dirname(process.execPath), '../include/node'),
17
+ ].filter(Boolean).find((p) => fs.existsSync(path.join(p, 'node_api.h')));
18
+ assert.ok(headers, 'Node headers required; run npm run rebuild first');
19
+ const binary = path.join(directory, 'ios-sync-probe.node');
20
+ execFileSync('xcrun', ['clang++', '-std=c++17', '-bundle', '-undefined', 'dynamic_lookup',
21
+ '-DNAPI_DISABLE_CPP_EXCEPTIONS', '-Wno-deprecated-declarations',
22
+ '-I', headers, '-I', path.dirname(require.resolve('node-addon-api')),
23
+ path.join(__dirname, 'ios-sync-probe.mm'), '-o', binary,
24
+ ...['Foundation', 'AVFoundation', 'CoreMedia', 'CoreVideo', 'CoreMediaIO', 'IOKit', 'CoreAudio']
25
+ .flatMap((framework) => ['-framework', framework]),
26
+ ], { timeout: 60000, stdio: 'pipe' });
27
+ const result = require(binary).run(directory);
28
+ t.diagnostic(`${result.checks} native timing and MOV checks`);
29
+ assert.equal(result.failures, '');
30
+ });