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 +1 -1
- package/src/audio_recorder.mm +67 -8
- package/src/camera_recorder.mm +24 -4
- package/src/ios_device_recorder.mm +77 -36
- package/src/sync_timeline.h +10 -0
- package/src/sync_timeline.mm +63 -1
- package/tests/ios-sync-probe.mm +249 -0
- package/tests/ios-sync.test.cjs +30 -0
package/package.json
CHANGED
package/src/audio_recorder.mm
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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);
|
package/src/camera_recorder.mm
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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.
|
|
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
|
-
//
|
|
732
|
-
//
|
|
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(
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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.
|
|
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
|
-
|
|
952
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
+
CMTime resumeHostTime = CMClockGetTime(CMClockGetHostTimeClock());
|
|
1011
|
+
MRIOSEndPauseRange(recorder, resumeHostTime);
|
|
971
1012
|
recorder.paused = NO;
|
|
972
|
-
|
|
1013
|
+
MRSyncResumeAtHostTime(resumeHostTime);
|
|
973
1014
|
return true;
|
|
974
1015
|
} @catch (NSException *exception) {
|
|
975
1016
|
MRLog(@"❌ iPhone resume failed: %@", exception.reason);
|
package/src/sync_timeline.h
CHANGED
|
@@ -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);
|
package/src/sync_timeline.mm
CHANGED
|
@@ -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 (
|
|
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
|
+
});
|