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 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` | Pattern: "help", "question" | ✅ |
23
- | `CURSOR_TYPES.progress` | `progress` | Pattern: "progress", "wait", "busy" | ✅ |
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` | Pattern: "zoom" + NOT "out" | ✅ |
30
- | `CURSOR_TYPES["zoom-out"]` | `zoom-out` | Pattern: "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` | maps to `row-resize` | ✅ |
38
- | `CURSOR_TYPES["nwse-resize"]` | `nwse-resize` | Pattern: "diagonal-down", "nwse" | ✅ |
39
- | `CURSOR_TYPES["nesw-resize"]` | `nesw-resize` | Pattern: "diagonal-up", "nesw" | ✅ |
40
- | `CURSOR_TYPES["all-scroll"]` | `all-scroll` | Pattern: "all-scroll", "omnidirectional" | ✅ |
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
- ## 🔧 Detection Methods
53
+ ## Algılama sırası
54
54
 
55
- ### 1. Direct NSCursor Equality (En Güvenilir)
56
- ```objc
57
- if (cursor == [NSCursor arrowCursor]) return @"default";
58
- if (cursor == [NSCursor IBeamCursor]) return @"text";
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
- ### 3. Shape-Based Detection (Hotspot + Aspect Ratio)
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
- ### 4. Accessibility API (Context-Aware)
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
- ## 📝 Notlar
65
+ ## Test
80
66
 
81
- ### İyileştirmeler (Latest)
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
- ### Bilinen Sınırlamalar
88
- - Custom cursor'lar (resim-based) sadece pattern matching ile detect edilir
89
- - Bazı uygulamalar custom cursor implementation kullanır (tam detection garanti edilemez)
90
- - Shape-based detection sadece temel cursor'lar için optimize edilmiş
69
+ ```sh
70
+ npm run test:cursor
71
+ ```
91
72
 
92
- ## 🧪 Test Etme
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
- Cursor detection'ı test etmek için:
75
+ Gerçek WindowServer imlecini de doğrulamak için:
95
76
 
96
- ```javascript
97
- const MacRecorder = require('./index.js');
98
- const recorder = new MacRecorder();
77
+ ```sh
78
+ MAC_RECORDER_TEST_LIVE_CURSOR=1 npm run test:cursor
79
+ ```
99
80
 
100
- // Start cursor tracking
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
- // Move mouse over different UI elements
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
- // Check cursor-test.json for detected cursor types
85
+ ```sh
86
+ MAC_RECORDER_ELECTRON=/path/to/Electron.app/Contents/MacOS/Electron npm run test:cursor:electron
110
87
  ```
111
88
 
112
- Her cursor event'inde `cursorType` field'ı Electron constant'larınızla uyumlu olacaktır.
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.15",
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",
@@ -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];