node-mac-recorder 2.20.16 → 2.21.0

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.
@@ -0,0 +1,677 @@
1
+ #import <AVFoundation/AVFoundation.h>
2
+ #import <CoreMedia/CoreMedia.h>
3
+ #import <CoreVideo/CoreVideo.h>
4
+ #import <Foundation/Foundation.h>
5
+ #import "logging.h"
6
+
7
+ #ifndef AVVideoCodecTypeVP9
8
+ static AVVideoCodecType const AVVideoCodecTypeVP9 = @"vp09";
9
+ #endif
10
+
11
+ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
12
+ if (!device) {
13
+ return NO;
14
+ }
15
+
16
+ if (@available(macOS 14.0, *)) {
17
+ if ([device.deviceType isEqualToString:AVCaptureDeviceTypeContinuityCamera]) {
18
+ return YES;
19
+ }
20
+ }
21
+
22
+ NSString *deviceType = device.deviceType ?: @"";
23
+ NSString *localizedName = device.localizedName ?: @"";
24
+ NSString *modelId = device.modelID ?: @"";
25
+ NSString *manufacturer = device.manufacturer ?: @"";
26
+
27
+ BOOL nameMentionsContinuity = [localizedName rangeOfString:@"Continuity" options:NSCaseInsensitiveSearch].location != NSNotFound ||
28
+ [modelId rangeOfString:@"Continuity" options:NSCaseInsensitiveSearch].location != NSNotFound;
29
+
30
+ if ([deviceType isEqualToString:AVCaptureDeviceTypeExternal] && nameMentionsContinuity) {
31
+ return YES;
32
+ }
33
+
34
+ if ([deviceType isEqualToString:AVCaptureDeviceTypeExternal] &&
35
+ [manufacturer rangeOfString:@"Apple" options:NSCaseInsensitiveSearch].location != NSNotFound &&
36
+ nameMentionsContinuity) {
37
+ return YES;
38
+ }
39
+
40
+ return NO;
41
+ }
42
+
43
+ // Dedicated camera recorder used alongside screen capture
44
+ @interface CameraRecorder : NSObject<AVCaptureVideoDataOutputSampleBufferDelegate>
45
+
46
+ @property (nonatomic, strong) AVCaptureSession *session;
47
+ @property (nonatomic, strong) AVCaptureDeviceInput *deviceInput;
48
+ @property (nonatomic, strong) AVCaptureVideoDataOutput *videoOutput;
49
+ @property (nonatomic, strong) AVAssetWriter *assetWriter;
50
+ @property (nonatomic, strong) AVAssetWriterInput *assetWriterInput;
51
+ @property (nonatomic, strong) AVAssetWriterInputPixelBufferAdaptor *pixelBufferAdaptor;
52
+ @property (nonatomic, strong) dispatch_queue_t captureQueue;
53
+ @property (nonatomic, strong) NSString *outputPath;
54
+ @property (atomic, assign) BOOL isRecording;
55
+ @property (atomic, assign) BOOL writerStarted;
56
+ @property (atomic, assign) BOOL isShuttingDown;
57
+ @property (nonatomic, assign) CMTime firstSampleTime;
58
+
59
+ + (instancetype)sharedRecorder;
60
+ + (NSArray<NSDictionary *> *)availableCameraDevices;
61
+ - (BOOL)startRecordingWithDeviceId:(NSString *)deviceId
62
+ outputPath:(NSString *)outputPath
63
+ error:(NSError **)error;
64
+ - (BOOL)stopRecording;
65
+
66
+ @end
67
+
68
+ @implementation CameraRecorder
69
+
70
+ + (instancetype)sharedRecorder {
71
+ static CameraRecorder *recorder = nil;
72
+ static dispatch_once_t onceToken;
73
+ dispatch_once(&onceToken, ^{
74
+ recorder = [[CameraRecorder alloc] init];
75
+ });
76
+ return recorder;
77
+ }
78
+
79
+ + (NSArray<NSDictionary *> *)availableCameraDevices {
80
+ NSMutableArray<NSDictionary *> *devicesInfo = [NSMutableArray array];
81
+
82
+ NSMutableArray<AVCaptureDeviceType> *deviceTypes = [NSMutableArray array];
83
+ [deviceTypes addObject:AVCaptureDeviceTypeExternalUnknown];
84
+ if (@available(macOS 10.15, *)) {
85
+ [deviceTypes addObject:AVCaptureDeviceTypeBuiltInWideAngleCamera];
86
+ if (@available(macOS 14.0, *)) {
87
+ [deviceTypes addObject:AVCaptureDeviceTypeContinuityCamera];
88
+ }
89
+ } else {
90
+ [deviceTypes addObject:AVCaptureDeviceTypeBuiltInWideAngleCamera];
91
+ }
92
+
93
+ AVCaptureDeviceDiscoverySession *discoverySession =
94
+ [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:deviceTypes
95
+ mediaType:AVMediaTypeVideo
96
+ position:AVCaptureDevicePositionUnspecified];
97
+
98
+ for (AVCaptureDevice *device in discoverySession.devices) {
99
+ BOOL continuityCamera = MRIsContinuityCamera(device);
100
+
101
+ // Determine the best (maximum) resolution format for this device
102
+ CMVideoDimensions bestDimensions = {0, 0};
103
+ Float64 bestFrameRate = 0.0;
104
+
105
+ for (AVCaptureDeviceFormat *format in device.formats) {
106
+ CMVideoDimensions dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription);
107
+
108
+ // Skip invalid formats
109
+ if (dims.width <= 0 || dims.height <= 0) {
110
+ continue;
111
+ }
112
+
113
+ Float64 maxFrameRateForFormat = 0.0;
114
+ for (AVFrameRateRange *range in format.videoSupportedFrameRateRanges) {
115
+ maxFrameRateForFormat = MAX(maxFrameRateForFormat, range.maxFrameRate);
116
+ }
117
+
118
+ bool isBetterResolution = (dims.width * dims.height) > (bestDimensions.width * bestDimensions.height);
119
+ bool sameResolutionHigherFps = (dims.width * dims.height) == (bestDimensions.width * bestDimensions.height) &&
120
+ maxFrameRateForFormat > bestFrameRate;
121
+
122
+ if (isBetterResolution || sameResolutionHigherFps) {
123
+ bestDimensions = dims;
124
+ bestFrameRate = maxFrameRateForFormat;
125
+ }
126
+ }
127
+
128
+ NSString *position;
129
+ switch (device.position) {
130
+ case AVCaptureDevicePositionFront:
131
+ position = @"front";
132
+ break;
133
+ case AVCaptureDevicePositionBack:
134
+ position = @"back";
135
+ break;
136
+ default:
137
+ position = @"unspecified";
138
+ break;
139
+ }
140
+
141
+ NSDictionary *deviceInfo = @{
142
+ @"id": device.uniqueID ?: @"",
143
+ @"name": device.localizedName ?: @"Unknown Camera",
144
+ @"model": device.modelID ?: @"",
145
+ @"manufacturer": device.manufacturer ?: @"",
146
+ @"position": position ?: @"unspecified",
147
+ @"transportType": @(device.transportType),
148
+ @"isConnected": @(device.isConnected),
149
+ @"hasFlash": @(device.hasFlash),
150
+ @"supportsDepth": @NO,
151
+ @"deviceType": device.deviceType ?: @"",
152
+ @"requiresContinuityCameraPermission": @(continuityCamera),
153
+ @"maxResolution": @{
154
+ @"width": @(bestDimensions.width),
155
+ @"height": @(bestDimensions.height),
156
+ @"maxFrameRate": @(bestFrameRate)
157
+ }
158
+ };
159
+
160
+ [devicesInfo addObject:deviceInfo];
161
+ }
162
+
163
+ return devicesInfo;
164
+ }
165
+
166
+ - (void)resetState {
167
+ self.writerStarted = NO;
168
+ self.isRecording = NO;
169
+ self.isShuttingDown = NO;
170
+ self.firstSampleTime = kCMTimeInvalid;
171
+ self.session = nil;
172
+ self.deviceInput = nil;
173
+ self.videoOutput = nil;
174
+ self.assetWriter = nil;
175
+ self.assetWriterInput = nil;
176
+ self.pixelBufferAdaptor = nil;
177
+ self.outputPath = nil;
178
+ self.captureQueue = nil;
179
+ }
180
+
181
+ - (AVCaptureDevice *)deviceForId:(NSString *)deviceId {
182
+ if (deviceId && deviceId.length > 0) {
183
+ AVCaptureDevice *device = [AVCaptureDevice deviceWithUniqueID:deviceId];
184
+ if (device) {
185
+ return device;
186
+ }
187
+ }
188
+
189
+ AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
190
+ if (!device) {
191
+ NSArray<NSDictionary *> *devices = [CameraRecorder availableCameraDevices];
192
+ if (devices.count > 0) {
193
+ NSString *fallbackId = devices.firstObject[@"id"];
194
+ device = [AVCaptureDevice deviceWithUniqueID:fallbackId];
195
+ }
196
+ }
197
+ return device;
198
+ }
199
+
200
+ - (AVCaptureDeviceFormat *)bestFormatForDevice:(AVCaptureDevice *)device
201
+ widthOut:(int32_t *)widthOut
202
+ heightOut:(int32_t *)heightOut
203
+ frameRateOut:(double *)frameRateOut {
204
+ AVCaptureDeviceFormat *bestFormat = nil;
205
+ int64_t bestResolutionScore = 0;
206
+ double bestFrameRate = 0.0;
207
+
208
+ for (AVCaptureDeviceFormat *format in device.formats) {
209
+ CMVideoDimensions dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription);
210
+ if (dims.width <= 0 || dims.height <= 0) {
211
+ continue;
212
+ }
213
+
214
+ int64_t score = (int64_t)dims.width * (int64_t)dims.height;
215
+
216
+ double maxFrameRate = 0.0;
217
+ for (AVFrameRateRange *range in format.videoSupportedFrameRateRanges) {
218
+ maxFrameRate = MAX(maxFrameRate, range.maxFrameRate);
219
+ }
220
+
221
+ BOOL usesBetterResolution = score > bestResolutionScore;
222
+ BOOL sameResolutionHigherFps = (score == bestResolutionScore) && (maxFrameRate > bestFrameRate);
223
+
224
+ if (!bestFormat || usesBetterResolution || sameResolutionHigherFps) {
225
+ bestFormat = format;
226
+ bestResolutionScore = score;
227
+ bestFrameRate = maxFrameRate;
228
+ if (widthOut) *widthOut = dims.width;
229
+ if (heightOut) *heightOut = dims.height;
230
+ if (frameRateOut) *frameRateOut = bestFrameRate;
231
+ }
232
+ }
233
+
234
+ return bestFormat;
235
+ }
236
+
237
+ - (BOOL)configureDevice:(AVCaptureDevice *)device
238
+ withFormat:(AVCaptureDeviceFormat *)format
239
+ frameRate:(double)frameRate
240
+ error:(NSError **)error {
241
+ if (!device || !format) {
242
+ return NO;
243
+ }
244
+
245
+ NSError *lockError = nil;
246
+ if (![device lockForConfiguration:&lockError]) {
247
+ if (error) {
248
+ *error = lockError;
249
+ }
250
+ return NO;
251
+ }
252
+
253
+ @try {
254
+ if ([device.formats containsObject:format]) {
255
+ device.activeFormat = format;
256
+ }
257
+
258
+ // Clamp desired frame rate within supported ranges
259
+ double targetFrameRate = frameRate > 0 ? frameRate : 30.0;
260
+ AVFrameRateRange *bestRange = nil;
261
+ for (AVFrameRateRange *range in format.videoSupportedFrameRateRanges) {
262
+ if (!bestRange || range.maxFrameRate > bestRange.maxFrameRate) {
263
+ bestRange = range;
264
+ }
265
+ }
266
+
267
+ if (bestRange) {
268
+ double clampedRate = MIN(bestRange.maxFrameRate, MAX(bestRange.minFrameRate, targetFrameRate));
269
+ CMTime frameDuration = CMTimeMake(1, (int32_t)round(clampedRate));
270
+ device.activeVideoMinFrameDuration = frameDuration;
271
+ device.activeVideoMaxFrameDuration = frameDuration;
272
+ }
273
+ } @catch (NSException *exception) {
274
+ if (error) {
275
+ NSDictionary *userInfo = @{
276
+ NSLocalizedDescriptionKey: exception.reason ?: @"Failed to configure camera device"
277
+ };
278
+ *error = [NSError errorWithDomain:@"CameraRecorder"
279
+ code:-4
280
+ userInfo:userInfo];
281
+ }
282
+ [device unlockForConfiguration];
283
+ return NO;
284
+ }
285
+
286
+ [device unlockForConfiguration];
287
+ return YES;
288
+ }
289
+
290
+ - (BOOL)setupWriterWithURL:(NSURL *)outputURL
291
+ width:(int32_t)width
292
+ height:(int32_t)height
293
+ frameRate:(double)frameRate
294
+ error:(NSError **)error {
295
+ if (!outputURL) {
296
+ return NO;
297
+ }
298
+
299
+ NSString *extension = outputURL.pathExtension.lowercaseString;
300
+ BOOL wantsWebM = [extension isEqualToString:@"webm"];
301
+
302
+ NSString *codec = AVVideoCodecTypeH264;
303
+ AVFileType fileType = AVFileTypeQuickTimeMovie;
304
+ BOOL webMSupported = NO;
305
+
306
+ if (wantsWebM) {
307
+ if (@available(macOS 15.0, *)) {
308
+ codec = AVVideoCodecTypeVP9;
309
+ fileType = @"public.webm";
310
+ webMSupported = YES;
311
+ MRLog(@"📹 CameraRecorder: Using VP9 codec for WebM output");
312
+ } else {
313
+ MRLog(@"⚠️ CameraRecorder: WebM output requested but not supported on this macOS version. Falling back to .mov");
314
+ }
315
+ }
316
+
317
+ NSError *writerError = nil;
318
+ self.assetWriter = [[AVAssetWriter alloc] initWithURL:outputURL fileType:fileType error:&writerError];
319
+
320
+ if (!self.assetWriter || writerError) {
321
+ if (error) {
322
+ *error = writerError;
323
+ }
324
+ return NO;
325
+ }
326
+
327
+ // On fallback, if WebM was requested but not supported, log and switch extension to .mov
328
+ if (wantsWebM && !webMSupported) {
329
+ MRLog(@"ℹ️ CameraRecorder: WebM unavailable, storing data in QuickTime container");
330
+ }
331
+
332
+ NSInteger bitrate = (NSInteger)(width * height * 6); // Empirical bitrate multiplier
333
+ bitrate = MAX(bitrate, 5 * 1000 * 1000); // Minimum 5 Mbps
334
+
335
+ NSMutableDictionary *compressionProps = [@{
336
+ AVVideoAverageBitRateKey: @(bitrate),
337
+ AVVideoMaxKeyFrameIntervalKey: @(MAX(1, (int)round(frameRate))),
338
+ AVVideoAllowFrameReorderingKey: @YES
339
+ } mutableCopy];
340
+
341
+ if ([codec isEqualToString:AVVideoCodecTypeH264]) {
342
+ compressionProps[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel;
343
+ }
344
+
345
+ NSDictionary *videoSettings = @{
346
+ AVVideoCodecKey: codec,
347
+ AVVideoWidthKey: @(width),
348
+ AVVideoHeightKey: @(height),
349
+ AVVideoCompressionPropertiesKey: compressionProps
350
+ };
351
+
352
+ // Video-only writer input (camera recordings remain silent by design)
353
+ self.assetWriterInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo
354
+ outputSettings:videoSettings];
355
+ self.assetWriterInput.expectsMediaDataInRealTime = YES;
356
+
357
+ NSDictionary *pixelBufferAttributes = @{
358
+ (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange),
359
+ (NSString *)kCVPixelBufferWidthKey: @(width),
360
+ (NSString *)kCVPixelBufferHeightKey: @(height)
361
+ };
362
+
363
+ self.pixelBufferAdaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:self.assetWriterInput
364
+ sourcePixelBufferAttributes:pixelBufferAttributes];
365
+
366
+ if (![self.assetWriter canAddInput:self.assetWriterInput]) {
367
+ if (error) {
368
+ NSDictionary *userInfo = @{
369
+ NSLocalizedDescriptionKey: @"Unable to attach video input to asset writer"
370
+ };
371
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-5 userInfo:userInfo];
372
+ }
373
+ return NO;
374
+ }
375
+
376
+ [self.assetWriter addInput:self.assetWriterInput];
377
+ self.writerStarted = NO;
378
+ self.firstSampleTime = kCMTimeInvalid;
379
+
380
+ return YES;
381
+ }
382
+
383
+ - (BOOL)startRecordingWithDeviceId:(NSString *)deviceId
384
+ outputPath:(NSString *)outputPath
385
+ error:(NSError **)error {
386
+ if (self.isRecording) {
387
+ if (error) {
388
+ NSDictionary *userInfo = @{
389
+ NSLocalizedDescriptionKey: @"Camera recording already in progress"
390
+ };
391
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-1 userInfo:userInfo];
392
+ }
393
+ return NO;
394
+ }
395
+
396
+ if (!outputPath || outputPath.length == 0) {
397
+ if (error) {
398
+ NSDictionary *userInfo = @{
399
+ NSLocalizedDescriptionKey: @"Invalid camera output path"
400
+ };
401
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-2 userInfo:userInfo];
402
+ }
403
+ return NO;
404
+ }
405
+
406
+ // Ensure camera permission
407
+ __block BOOL cameraPermissionGranted = YES;
408
+ AVAuthorizationStatus cameraStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
409
+ if (cameraStatus == AVAuthorizationStatusNotDetermined) {
410
+ dispatch_semaphore_t permissionSemaphore = dispatch_semaphore_create(0);
411
+ [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
412
+ cameraPermissionGranted = granted;
413
+ dispatch_semaphore_signal(permissionSemaphore);
414
+ }];
415
+ dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC));
416
+ dispatch_semaphore_wait(permissionSemaphore, timeout);
417
+ } else if (cameraStatus != AVAuthorizationStatusAuthorized) {
418
+ cameraPermissionGranted = NO;
419
+ }
420
+
421
+ if (!cameraPermissionGranted) {
422
+ if (error) {
423
+ NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: @"Camera permission not granted" };
424
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-4 userInfo:userInfo];
425
+ }
426
+ return NO;
427
+ }
428
+
429
+ // Remove any stale file
430
+ NSError *removeError = nil;
431
+ [[NSFileManager defaultManager] removeItemAtPath:outputPath error:&removeError];
432
+ if (removeError && removeError.code != NSFileNoSuchFileError) {
433
+ MRLog(@"⚠️ CameraRecorder: Failed to remove existing camera file: %@", removeError);
434
+ }
435
+
436
+ AVCaptureDevice *device = [self deviceForId:deviceId];
437
+ if (!device) {
438
+ if (error) {
439
+ NSDictionary *userInfo = @{
440
+ NSLocalizedDescriptionKey: @"No camera devices available"
441
+ };
442
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-3 userInfo:userInfo];
443
+ }
444
+ return NO;
445
+ }
446
+
447
+ BOOL isContinuityCamera = MRIsContinuityCamera(device);
448
+ if (isContinuityCamera) {
449
+ id continuityKey = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSCameraUseContinuityCameraDeviceType"];
450
+ BOOL allowContinuity = NO;
451
+ if ([continuityKey respondsToSelector:@selector(boolValue)]) {
452
+ allowContinuity = [continuityKey boolValue];
453
+ }
454
+ if (!allowContinuity && getenv("ALLOW_CONTINUITY_CAMERA")) {
455
+ allowContinuity = YES;
456
+ }
457
+ if (!allowContinuity) {
458
+ if (error) {
459
+ NSDictionary *userInfo = @{
460
+ NSLocalizedDescriptionKey: @"Continuity Camera requires NSCameraUseContinuityCameraDeviceType=true in Info.plist"
461
+ };
462
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-5 userInfo:userInfo];
463
+ }
464
+ MRLog(@"⚠️ Continuity Camera access denied - missing Info.plist entitlement");
465
+ return NO;
466
+ }
467
+ }
468
+
469
+ int32_t width = 0;
470
+ int32_t height = 0;
471
+ double frameRate = 0.0;
472
+ AVCaptureDeviceFormat *bestFormat = [self bestFormatForDevice:device widthOut:&width heightOut:&height frameRateOut:&frameRate];
473
+
474
+ if (![self configureDevice:device withFormat:bestFormat frameRate:frameRate error:error]) {
475
+ return NO;
476
+ }
477
+
478
+ self.session = [[AVCaptureSession alloc] init];
479
+
480
+ self.deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:error];
481
+ if (!self.deviceInput) {
482
+ [self resetState];
483
+ return NO;
484
+ }
485
+
486
+ if ([self.session canAddInput:self.deviceInput]) {
487
+ [self.session addInput:self.deviceInput];
488
+ } else {
489
+ if (error) {
490
+ NSDictionary *userInfo = @{
491
+ NSLocalizedDescriptionKey: @"Unable to add camera input to capture session"
492
+ };
493
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-6 userInfo:userInfo];
494
+ }
495
+ [self resetState];
496
+ return NO;
497
+ }
498
+
499
+ self.videoOutput = [[AVCaptureVideoDataOutput alloc] init];
500
+ self.videoOutput.alwaysDiscardsLateVideoFrames = NO;
501
+ self.videoOutput.videoSettings = @{
502
+ (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
503
+ };
504
+
505
+ self.captureQueue = dispatch_queue_create("node_mac_recorder.camera.queue", DISPATCH_QUEUE_SERIAL);
506
+ [self.videoOutput setSampleBufferDelegate:self queue:self.captureQueue];
507
+
508
+ if ([self.session canAddOutput:self.videoOutput]) {
509
+ [self.session addOutput:self.videoOutput];
510
+ } else {
511
+ if (error) {
512
+ NSDictionary *userInfo = @{
513
+ NSLocalizedDescriptionKey: @"Unable to add camera output to capture session"
514
+ };
515
+ *error = [NSError errorWithDomain:@"CameraRecorder" code:-7 userInfo:userInfo];
516
+ }
517
+ [self resetState];
518
+ return NO;
519
+ }
520
+
521
+ AVCaptureConnection *connection = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo];
522
+ if (connection) {
523
+ if (connection.isVideoOrientationSupported) {
524
+ connection.videoOrientation = AVCaptureVideoOrientationLandscapeRight;
525
+ }
526
+ if (connection.isVideoMirroringSupported && device.position == AVCaptureDevicePositionFront) {
527
+ connection.videoMirrored = YES;
528
+ }
529
+ }
530
+
531
+ NSURL *outputURL = [NSURL fileURLWithPath:outputPath];
532
+ if (![self setupWriterWithURL:outputURL width:width height:height frameRate:frameRate error:error]) {
533
+ [self.session stopRunning];
534
+ [self resetState];
535
+ return NO;
536
+ }
537
+
538
+ self.outputPath = outputPath;
539
+ self.isRecording = YES;
540
+ self.isShuttingDown = NO;
541
+
542
+ [self.session startRunning];
543
+
544
+ MRLog(@"🎥 CameraRecorder started: %@ (%dx%d @ %.2ffps)", device.localizedName, width, height, frameRate);
545
+ return YES;
546
+ }
547
+
548
+ - (BOOL)stopRecording {
549
+ if (!self.isRecording) {
550
+ return YES;
551
+ }
552
+
553
+ self.isShuttingDown = YES;
554
+ self.isRecording = NO;
555
+
556
+ @try {
557
+ [self.session stopRunning];
558
+ } @catch (NSException *exception) {
559
+ MRLog(@"⚠️ CameraRecorder: Exception while stopping session: %@", exception.reason);
560
+ }
561
+
562
+ [self.videoOutput setSampleBufferDelegate:nil queue:nil];
563
+
564
+ if (self.assetWriterInput) {
565
+ [self.assetWriterInput markAsFinished];
566
+ }
567
+
568
+ __block BOOL finished = NO;
569
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
570
+
571
+ [self.assetWriter finishWritingWithCompletionHandler:^{
572
+ finished = YES;
573
+ dispatch_semaphore_signal(semaphore);
574
+ }];
575
+
576
+ dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC));
577
+ dispatch_semaphore_wait(semaphore, timeout);
578
+
579
+ if (!finished) {
580
+ MRLog(@"⚠️ CameraRecorder: Timed out waiting for writer to finish");
581
+ }
582
+
583
+ BOOL success = (self.assetWriter.status == AVAssetWriterStatusCompleted);
584
+ if (!success) {
585
+ MRLog(@"⚠️ CameraRecorder: Writer finished with status %ld error %@", (long)self.assetWriter.status, self.assetWriter.error);
586
+ } else {
587
+ MRLog(@"✅ CameraRecorder stopped successfully");
588
+ }
589
+
590
+ [self resetState];
591
+ return success;
592
+ }
593
+
594
+ - (void)captureOutput:(AVCaptureOutput *)output
595
+ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
596
+ fromConnection:(AVCaptureConnection *)connection {
597
+ if (!self.isRecording || self.isShuttingDown) {
598
+ return;
599
+ }
600
+
601
+ if (!sampleBuffer) {
602
+ return;
603
+ }
604
+
605
+ CMTime timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
606
+ if (!self.writerStarted) {
607
+ if (self.assetWriter.status == AVAssetWriterStatusUnknown) {
608
+ if ([self.assetWriter startWriting]) {
609
+ [self.assetWriter startSessionAtSourceTime:timestamp];
610
+ self.writerStarted = YES;
611
+ self.firstSampleTime = timestamp;
612
+ } else {
613
+ MRLog(@"❌ CameraRecorder: Failed to start asset writer: %@", self.assetWriter.error);
614
+ self.isRecording = NO;
615
+ return;
616
+ }
617
+ }
618
+ }
619
+
620
+ if (!self.writerStarted || self.assetWriter.status != AVAssetWriterStatusWriting) {
621
+ return;
622
+ }
623
+
624
+ if (!self.assetWriterInput.readyForMoreMediaData) {
625
+ return;
626
+ }
627
+
628
+ CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
629
+ if (!pixelBuffer) {
630
+ return;
631
+ }
632
+
633
+ CVPixelBufferRetain(pixelBuffer);
634
+ BOOL appended = [self.pixelBufferAdaptor appendPixelBuffer:pixelBuffer withPresentationTime:timestamp];
635
+ CVPixelBufferRelease(pixelBuffer);
636
+
637
+ if (!appended) {
638
+ MRLog(@"⚠️ CameraRecorder: Failed to append camera frame at time %.2f (status %ld)",
639
+ CMTimeGetSeconds(timestamp), (long)self.assetWriter.status);
640
+ if (self.assetWriter.status == AVAssetWriterStatusFailed) {
641
+ MRLog(@"❌ CameraRecorder writer failure: %@", self.assetWriter.error);
642
+ self.isRecording = NO;
643
+ }
644
+ }
645
+ }
646
+
647
+ @end
648
+
649
+ // MARK: - C Interface
650
+
651
+ extern "C" {
652
+
653
+ NSArray<NSDictionary *> *listCameraDevices() {
654
+ @autoreleasepool {
655
+ return [CameraRecorder availableCameraDevices];
656
+ }
657
+ }
658
+
659
+ bool startCameraRecording(NSString *outputPath, NSString *deviceId, NSError **error) {
660
+ @autoreleasepool {
661
+ return [[CameraRecorder sharedRecorder] startRecordingWithDeviceId:deviceId
662
+ outputPath:outputPath
663
+ error:error];
664
+ }
665
+ }
666
+
667
+ bool stopCameraRecording() {
668
+ @autoreleasepool {
669
+ return [[CameraRecorder sharedRecorder] stopRecording];
670
+ }
671
+ }
672
+
673
+ bool isCameraRecording() {
674
+ return [CameraRecorder sharedRecorder].isRecording;
675
+ }
676
+
677
+ }