node-mac-recorder 2.24.15 → 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/CURSOR_MAPPING.md +33 -54
- package/package.json +3 -1
- package/src/audio_recorder.mm +67 -8
- package/src/camera_recorder.mm +24 -4
- package/src/cursor_tracker.mm +191 -780
- package/src/ios_device_recorder.mm +77 -36
- package/src/sync_timeline.h +10 -0
- package/src/sync_timeline.mm +63 -1
- package/tests/cursor-detection-probe.mm +207 -0
- package/tests/cursor-detection.test.cjs +37 -0
- package/tests/cursor-electron.test.cjs +26 -0
- package/tests/electron-cursor-fixture.cjs +67 -0
- package/tests/ios-sync-probe.mm +249 -0
- package/tests/ios-sync.test.cjs +30 -0
package/CURSOR_MAPPING.md
CHANGED
|
@@ -19,25 +19,25 @@ Bu dosya, macOS native cursor type'larının Electron/CSS cursor constant'ların
|
|
|
19
19
|
| `CURSOR_TYPES.copy` | `copy` | `NSCursor.dragCopyCursor` | ✅ |
|
|
20
20
|
| `CURSOR_TYPES.alias` | `alias` | `NSCursor.dragLinkCursor` | ✅ |
|
|
21
21
|
| `CURSOR_TYPES["not-allowed"]` | `not-allowed` | `NSCursor.operationNotAllowedCursor` | ✅ |
|
|
22
|
-
| `CURSOR_TYPES.help` | `help` |
|
|
23
|
-
| `CURSOR_TYPES.progress` | `progress` |
|
|
22
|
+
| `CURSOR_TYPES.help` | `help` | HIServices `help` PNG/PDF | ✅ |
|
|
23
|
+
| `CURSOR_TYPES.progress` | `progress` | HIServices `busybutclickable` | ✅ |
|
|
24
24
|
|
|
25
25
|
### Zoom Cursor'lar
|
|
26
26
|
| Electron Constant | CSS Value | macOS Native | Durum |
|
|
27
27
|
|-------------------|-----------|--------------|--------|
|
|
28
28
|
| `CURSOR_TYPES.crosshair` | `crosshair` | `NSCursor.crosshairCursor` | ✅ |
|
|
29
|
-
| `CURSOR_TYPES["zoom-in"]` | `zoom-in` |
|
|
30
|
-
| `CURSOR_TYPES["zoom-out"]` | `zoom-out` |
|
|
29
|
+
| `CURSOR_TYPES["zoom-in"]` | `zoom-in` | `NSCursor.zoomInCursor` / HIServices `zoomin` | ✅ |
|
|
30
|
+
| `CURSOR_TYPES["zoom-out"]` | `zoom-out` | `NSCursor.zoomOutCursor` / HIServices `zoomout` | ✅ |
|
|
31
31
|
|
|
32
32
|
### Resize Cursor'lar
|
|
33
33
|
| Electron Constant | CSS Value | macOS Native | Durum |
|
|
34
34
|
|-------------------|-----------|--------------|--------|
|
|
35
35
|
| `CURSOR_TYPES["row-resize"]` | `row-resize` | `NSCursor.resizeUpDownCursor` | ✅ |
|
|
36
36
|
| `CURSOR_TYPES["col-resize"]` | `col-resize` | `NSCursor.resizeLeftRightCursor` | ✅ |
|
|
37
|
-
| `CURSOR_TYPES["ns-resize"]` | `ns-resize` |
|
|
38
|
-
| `CURSOR_TYPES["nwse-resize"]` | `nwse-resize` |
|
|
39
|
-
| `CURSOR_TYPES["nesw-resize"]` | `nesw-resize` |
|
|
40
|
-
| `CURSOR_TYPES["all-scroll"]` | `all-scroll` |
|
|
37
|
+
| `CURSOR_TYPES["ns-resize"]` | `ns-resize` | Dikey frame resize; `row-resize` ayrı tutulur | ✅ |
|
|
38
|
+
| `CURSOR_TYPES["nwse-resize"]` | `nwse-resize` | Frame resize: sol üst / sağ alt | ✅ |
|
|
39
|
+
| `CURSOR_TYPES["nesw-resize"]` | `nesw-resize` | Frame resize: sağ üst / sol alt | ✅ |
|
|
40
|
+
| `CURSOR_TYPES["all-scroll"]` | `all-scroll` | HIServices `move` | ✅ |
|
|
41
41
|
|
|
42
42
|
### Mouse Events
|
|
43
43
|
| Electron Constant | Event Type | Native Detection | Durum |
|
|
@@ -50,63 +50,42 @@ Bu dosya, macOS native cursor type'larının Electron/CSS cursor constant'ların
|
|
|
50
50
|
| `MOUSE_EVENTS.WHEEL` | `wheel` | Scroll wheel events | ✅ |
|
|
51
51
|
| `MOUSE_EVENTS.HOVER` | `hover` | Position stability | ✅ |
|
|
52
52
|
|
|
53
|
-
##
|
|
53
|
+
## Algılama sırası
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
### 2. Pattern Matching (Cursor Name/Description)
|
|
62
|
-
```objc
|
|
63
|
-
if ([normalized containsString:@"resize"]) { ... }
|
|
64
|
-
if ([normalized containsString:@"zoom"]) { ... }
|
|
65
|
-
```
|
|
55
|
+
1. `NSCursor.currentSystemCursor` ile ekranın gerçek imleci alınır. Kayıt işlemi arka plandayken uygulamanın kendi `currentCursor` değeri kullanılmaz.
|
|
56
|
+
2. Görselin piksel içeriği ve hotspot'u sistem imleçlerinin fingerprint'leriyle karşılaştırılır. AppKit görselleri ile HIServices PNG/PDF kaynaklarının 1x ve 2x temsilleri tanıtılır. Chromium/WebKit'in `move` gibi CoreCursor varyantları AppKit üzerinden, sistem gölgesi dahil render edilir; ham PDF aynı piksel görüntüsünü üretmez.
|
|
57
|
+
3. macOS 15 ve üzerinde frame resize'ın sekiz kenar/köşe konumu ve içe/dışa/iki yönlü çeşitleri; row/column resize'ın tek ve çift yönleri ayrı ayrı tanıtılır.
|
|
58
|
+
4. Görsel eşleşmezse bilinen imleç adları kullanılır. Tanınmayan görseller `default` olur; sadece boyutuna bakarak resize, zoom veya hand tipi atanmaz.
|
|
59
|
+
5. Sonuç desktop'taki SVG tiplerine normalize edilir. `context-menu` → `default`, `wait` → `progress`, `move` → `all-scroll`; tek yönlü resize tipleri ilgili eksene dönüştürülür.
|
|
66
60
|
|
|
67
|
-
|
|
68
|
-
```objc
|
|
69
|
-
// Text cursor: narrow (0.50), center hotspot (0.44, 0.50)
|
|
70
|
-
// Arrow cursor: medium (0.74), top-left hotspot (0.24, 0.17)
|
|
71
|
-
// Pointer cursor: square (1.00), left-center hotspot (0.41, 0.25)
|
|
72
|
-
```
|
|
61
|
+
`CGSCurrentCursorSeed` bir değişiklik sayacıdır; tip kimliği olarak kullanılmaz. Eski `MAC_RECORDER_CURSOR_MAP` dosyalarındaki `seed` alanları yok sayılır, fingerprint ve privateName eşleştirmeleri desteklenir. Accessibility tahminleri yalnızca debug çıktısındadır.
|
|
73
62
|
|
|
74
|
-
|
|
75
|
-
```objc
|
|
76
|
-
AXUIElementCopyElementAtPosition() → role → cursor type
|
|
77
|
-
```
|
|
63
|
+
Konum değişmese bile cursor tipi değiştiğinde hareket/drag örneği kaydedilir.
|
|
78
64
|
|
|
79
|
-
##
|
|
65
|
+
## Test
|
|
80
66
|
|
|
81
|
-
|
|
82
|
-
- ✅ `ns-resize` → `row-resize` mapping (Electron uyumluluğu)
|
|
83
|
-
- ✅ `all-scroll` pattern detection eklendi
|
|
84
|
-
- ✅ `progress` (wait/busy yerine)
|
|
85
|
-
- ✅ `contextualMenuCursor` → `pointer` mapping
|
|
67
|
+
macOS'ta, kurulu Node header'ları ve Xcode Command Line Tools ile:
|
|
86
68
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
- Shape-based detection sadece temel cursor'lar için optimize edilmiş
|
|
69
|
+
```sh
|
|
70
|
+
npm run test:cursor
|
|
71
|
+
```
|
|
91
72
|
|
|
92
|
-
|
|
73
|
+
Test üretim kodunu derleyerek AppKit imleçlerini, bunların bağımsız 1x/2x bitmap kopyalarını, eski sistem kaynaklarını, isim eşleştirmelerini ve sabit konumdaki şekil değişikliklerini kontrol eder. AppKit sistem görsellerine erişim gerekir.
|
|
93
74
|
|
|
94
|
-
|
|
75
|
+
Gerçek WindowServer imlecini de doğrulamak için:
|
|
95
76
|
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
|
|
77
|
+
```sh
|
|
78
|
+
MAC_RECORDER_TEST_LIVE_CURSOR=1 npm run test:cursor
|
|
79
|
+
```
|
|
99
80
|
|
|
100
|
-
|
|
101
|
-
await recorder.startCursorCapture('cursor-test.json', {
|
|
102
|
-
videoRelative: false
|
|
103
|
-
});
|
|
81
|
+
Bu seçenek imlecin altında kısa süreli bir test penceresi açar; test bitince pencere kapanır, önceki uygulama ve imleç geri yüklenir. Test sırasında fareyi hareket ettirmeyin.
|
|
104
82
|
|
|
105
|
-
|
|
106
|
-
// Stop after a few seconds
|
|
107
|
-
await recorder.stopCursorCapture();
|
|
83
|
+
Gerçek Chromium CSS imleçleri ve `grab → grabbing → grab` basma/bırakma geçişi için (önce `npm run build`):
|
|
108
84
|
|
|
109
|
-
|
|
85
|
+
```sh
|
|
86
|
+
MAC_RECORDER_ELECTRON=/path/to/Electron.app/Contents/MacOS/Electron npm run test:cursor:electron
|
|
110
87
|
```
|
|
111
88
|
|
|
112
|
-
|
|
89
|
+
Bu test de kısa süreli bir pencere açar. Electron zaten test ortamında kuruluysa executable değişkeni gerekmez.
|
|
90
|
+
|
|
91
|
+
Bilinmeyen özel uygulama görselleri veya gelecekte macOS'un sistem imleç görüntüsünü sağlamadığı durumlar `default` dönebilir. macOS 15 öncesindeki sistemler HIServices kaynakları ve mevcut NSCursor factory'leri üzerinden desteklenir.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-mac-recorder",
|
|
3
|
-
"version": "2.24.
|
|
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": [
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "node test.js",
|
|
37
|
+
"test:cursor": "node --test tests/cursor-detection.test.cjs",
|
|
38
|
+
"test:cursor:electron": "node --test tests/cursor-electron.test.cjs",
|
|
37
39
|
"install": "node install.js",
|
|
38
40
|
"build": "node-gyp build",
|
|
39
41
|
"rebuild": "node-gyp rebuild",
|
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];
|