ascii-chat 0.8.38
Real-time terminal-based video chat with ASCII art conversion
Loading...
Searching...
No Matches
webcam_avfoundation.m
Go to the documentation of this file.
1
7#ifdef __APPLE__
8
9#import <AVFoundation/AVFoundation.h>
10#import <CoreMedia/CoreMedia.h>
11#import <CoreVideo/CoreVideo.h>
12#include <dispatch/dispatch.h>
13
14#include <ascii-chat/video/webcam/webcam.h>
15#include <ascii-chat/common.h>
16#include <ascii-chat/util/image.h>
17
18// AVFoundation timeout configuration
19#define AVFOUNDATION_FRAME_TIMEOUT_NS 500000000 // 500ms timeout (adjustable for slow cameras)
20#define AVFOUNDATION_INIT_TIMEOUT_NS (3 * NSEC_PER_SEC) // 3 second timeout for initialization
21
22@interface WebcamCaptureDelegate : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate>
23@property(nonatomic, strong) dispatch_semaphore_t frameSemaphore;
24@property(nonatomic) CVPixelBufferRef currentFrame;
25@property(nonatomic) int frameWidth;
26@property(nonatomic) int frameHeight;
27@property(nonatomic) BOOL isActive;
28@property(nonatomic) BOOL hasNewFrame;
29@property(nonatomic, strong) NSLock *frameLock;
30@end
31
32@implementation WebcamCaptureDelegate
33
34- (instancetype)init {
35 self = [super init];
36 if (self) {
37 _frameSemaphore = dispatch_semaphore_create(0);
38 _currentFrame = NULL;
39 _frameWidth = 0;
40 _frameHeight = 0;
41 _isActive = YES;
42 _hasNewFrame = NO;
43 _frameLock = [[NSLock alloc] init];
44 }
45 return self;
46}
47
48- (void)dealloc {
49 [_frameLock lock];
50 if (_currentFrame) {
51 CVPixelBufferRelease(_currentFrame);
52 _currentFrame = NULL;
53 }
54 [_frameLock unlock];
55 [_frameLock release];
56 // Release the dispatch semaphore (required under MRC for dispatch objects)
57 if (_frameSemaphore) {
58 dispatch_release(_frameSemaphore);
59 _frameSemaphore = nil;
60 }
61 [super dealloc];
62}
63
64- (void)captureOutput:(AVCaptureOutput *)output
65 didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
66 fromConnection:(AVCaptureConnection *)connection {
67 // Check if delegate is still active to prevent callbacks during cleanup
68 if (!self.isActive) {
69 return;
70 }
71
72 CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
73 if (!pixelBuffer)
74 return;
75
76 [self.frameLock lock];
77
78 // Replace old frame with new one
79 CVPixelBufferRef oldFrame = self.currentFrame;
80 self.currentFrame = CVPixelBufferRetain(pixelBuffer);
81
82 if (oldFrame) {
83 CVPixelBufferRelease(oldFrame);
84 }
85
86 // Update dimensions
87 self.frameWidth = (int)CVPixelBufferGetWidth(pixelBuffer);
88 self.frameHeight = (int)CVPixelBufferGetHeight(pixelBuffer);
89
90 // Always signal semaphore when we get a new frame
91 self.hasNewFrame = YES;
92
93 [self.frameLock unlock];
94
95 dispatch_semaphore_signal(self.frameSemaphore);
96}
97
98- (void)deactivate {
99 self.isActive = NO;
100}
101
102@end
103
104struct webcam_context_t {
105 AVCaptureSession *session;
106 AVCaptureDeviceInput *input;
107 AVCaptureVideoDataOutput *output;
108 WebcamCaptureDelegate *delegate;
109 dispatch_queue_t queue;
110 int width;
111 int height;
112 image_t *cached_frame; // Reusable frame buffer (allocated once, reused for each read)
113};
114
115// Helper function to get supported device types without deprecated ones
116static NSArray *getSupportedDeviceTypes(void) {
117 NSMutableArray *deviceTypes = [NSMutableArray array];
118
119 // Always add built-in camera
120 [deviceTypes addObject:AVCaptureDeviceTypeBuiltInWideAngleCamera];
121
122 // Add continuity camera support for macOS 13.0+
123 if (@available(macOS 13.0, *)) {
124 [deviceTypes addObject:AVCaptureDeviceTypeContinuityCamera];
125 }
126
127 // Add external camera support for macOS 14.0+
128 if (@available(macOS 14.0, *)) {
129 [deviceTypes addObject:AVCaptureDeviceTypeExternal];
130 }
131
132 return [[deviceTypes copy] autorelease];
133}
134
135asciichat_error_t webcam_init_context(webcam_context_t **ctx, unsigned short int device_index) {
136 webcam_context_t *context;
137 context = SAFE_MALLOC(sizeof(webcam_context_t), webcam_context_t *);
138 if (!context) {
139 return SET_ERRNO(ERROR_MEMORY, "Failed to allocate webcam context");
140 }
141
142 memset(context, 0, sizeof(webcam_context_t));
143
144 @autoreleasepool {
145 // Create capture session
146 context->session = [[AVCaptureSession alloc] init];
147 if (!context->session) {
148 SAFE_FREE(context);
149 return SET_ERRNO(ERROR_WEBCAM, "Failed to create AVCaptureSession");
150 }
151
152 // Set session preset for quality
153 [context->session setSessionPreset:AVCaptureSessionPreset640x480];
154
155 // Find camera device using newer discovery session API with proper device types
156 AVCaptureDevice *device = nil;
157
158 NSArray *deviceTypes = getSupportedDeviceTypes();
159 AVCaptureDeviceDiscoverySession *discoverySession =
160 [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:deviceTypes
161 mediaType:AVMediaTypeVideo
162 position:AVCaptureDevicePositionUnspecified];
163
164 NSArray *devices = discoverySession.devices;
165
166 for (AVCaptureDevice *device in devices) {
167 NSUInteger idx = [devices indexOfObject:device];
168 log_debug("Found device %lu: %s (type: %s)", (unsigned long)idx, [device.localizedName UTF8String],
169 [device.deviceType UTF8String]);
170 }
171
172 if (device_index < [devices count]) {
173 device = [devices objectAtIndex:device_index];
174 } else if ([devices count] > 0) {
175 device = [devices objectAtIndex:0]; // Use first available camera
176 }
177
178 // Fallback to default device if discovery session fails
179 if (!device) {
180 device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
181 }
182
183 if (!device) {
184 log_error("No camera device found at index %d", device_index);
185 [context->session release];
186 SAFE_FREE(context);
187 return SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam");
188 }
189
190 // Create device input
191 NSError *error = nil;
192 context->input = [[AVCaptureDeviceInput alloc] initWithDevice:device error:&error];
193 if (!context->input || error) {
194 log_error("Failed to create device input: %s", [[error localizedDescription] UTF8String]);
195 [context->session release];
196 SAFE_FREE(context);
197 return SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam");
198 }
199
200 // Add input to session
201 if ([context->session canAddInput:context->input]) {
202 [context->session addInput:context->input];
203 } else {
204 log_error("Cannot add input to capture session");
205 [context->input release];
206 [context->session release];
207 SAFE_FREE(context);
208 return SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam");
209 }
210
211 // Create video output
212 context->output = [[AVCaptureVideoDataOutput alloc] init];
213 if (!context->output) {
214 log_error("Failed to create video output");
215 [context->input release];
216 [context->session release];
217 SAFE_FREE(context);
218 return SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam");
219 }
220
221 // Configure video output for RGB format
222 NSDictionary *videoSettings = @{(NSString *)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_24RGB)};
223 [context->output setVideoSettings:videoSettings];
224
225 // Create delegate and queue
226 context->delegate = [[WebcamCaptureDelegate alloc] init];
227 context->queue = dispatch_queue_create("webcam_capture_queue", DISPATCH_QUEUE_SERIAL);
228
229 [context->output setSampleBufferDelegate:context->delegate queue:context->queue];
230
231 // Add output to session
232 if ([context->session canAddOutput:context->output]) {
233 [context->session addOutput:context->output];
234 } else {
235 log_error("Cannot add output to capture session");
236 [context->delegate release];
237 [context->output release];
238 [context->input release];
239 [context->session release];
240 dispatch_release(context->queue);
241 SAFE_FREE(context);
242 return SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam");
243 }
244
245 // Start the session
246 log_debug("Starting AVFoundation capture session...");
247 [context->session startRunning];
248
249 // Check if session is actually running
250 if (![context->session isRunning]) {
251 log_error("Failed to start capture session - session not running");
252 [context->delegate release];
253 [context->output release];
254 [context->input release];
255 [context->session release];
256 dispatch_release(context->queue);
257 SAFE_FREE(context);
258 return SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam");
259 }
260 log_debug("Capture session started successfully");
261
262 // Wait for first frame to get dimensions
263 dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, AVFOUNDATION_INIT_TIMEOUT_NS);
264 if (dispatch_semaphore_wait(context->delegate.frameSemaphore, timeout) == 0) {
265 context->width = context->delegate.frameWidth;
266 context->height = context->delegate.frameHeight;
267 log_debug("AVFoundation webcam initialized: %dx%d, delegate isActive=%d", context->width, context->height,
268 context->delegate.isActive);
269 } else {
270 log_error("Timeout waiting for first frame");
271 [context->session stopRunning];
272 [context->delegate release];
273 [context->output release];
274 [context->input release];
275 [context->session release];
276 dispatch_release(context->queue);
277 SAFE_FREE(context);
278 return SET_ERRNO(ERROR_WEBCAM, "Failed to initialize webcam");
279 }
280 }
281
282 *ctx = context;
283 return ASCIICHAT_OK;
284}
285
286void webcam_flush_context(webcam_context_t *ctx) {
287 (void)ctx;
288 // No-op on macOS - AVFoundation handles frame buffering internally
289}
290
291void webcam_cleanup_context(webcam_context_t *ctx) {
292 if (!ctx)
293 return;
294
295 @autoreleasepool {
296 // First, deactivate delegate to reject new callbacks
297 if (ctx->delegate) {
298 [ctx->delegate deactivate];
299 }
300
301 // Remove delegate to prevent any further callbacks
302 if (ctx->output) {
303 [ctx->output setSampleBufferDelegate:nil queue:nil];
304 }
305
306 // Now stop the session
307 if (ctx->session && [ctx->session isRunning]) {
308 [ctx->session stopRunning];
309
310 // Wait longer for session to fully stop
311 usleep(200000); // 200ms - give more time for cleanup
312 }
313
314 // Clean up session and associated objects
315 if (ctx->session) {
316 if (ctx->input && [ctx->session.inputs containsObject:ctx->input]) {
317 [ctx->session removeInput:ctx->input];
318 }
319 if (ctx->output && [ctx->session.outputs containsObject:ctx->output]) {
320 [ctx->session removeOutput:ctx->output];
321 }
322 [ctx->session release];
323 ctx->session = nil;
324 }
325
326 if (ctx->input) {
327 [ctx->input release];
328 ctx->input = nil;
329 }
330
331 if (ctx->output) {
332 [ctx->output release];
333 ctx->output = nil;
334 }
335
336 if (ctx->delegate) {
337 [ctx->delegate release];
338 ctx->delegate = nil;
339 }
340
341 if (ctx->queue) {
342 dispatch_release(ctx->queue);
343 ctx->queue = nil;
344 }
345 }
346
347 // Free cached frame if it was allocated
348 if (ctx->cached_frame) {
349 image_destroy(ctx->cached_frame);
350 ctx->cached_frame = NULL;
351 }
352
353 SAFE_FREE(ctx);
354 // log_info("AVFoundation webcam cleaned up");
355}
356
357image_t *webcam_read_context(webcam_context_t *ctx) {
358 if (!ctx || !ctx->delegate || !ctx->delegate.isActive)
359 return NULL;
360
361 @autoreleasepool {
362 // Wait for a frame (configurable timeout for different camera speeds)
363 dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, AVFOUNDATION_FRAME_TIMEOUT_NS);
364 if (dispatch_semaphore_wait(ctx->delegate.frameSemaphore, timeout) != 0) {
365 log_error("No webcam frame available within timeout: %0.2f seconds",
366 AVFOUNDATION_FRAME_TIMEOUT_NS / NSEC_PER_SEC);
367 return NULL; // No frame available within timeout
368 }
369
370 // Reset the new frame flag so we can get the next semaphore signal
371 ctx->delegate.hasNewFrame = NO;
372
373 // Lock to safely access the current frame
374 [ctx->delegate.frameLock lock];
375
376 CVPixelBufferRef pixelBuffer = ctx->delegate.currentFrame;
377 if (!pixelBuffer) {
378 [ctx->delegate.frameLock unlock];
379 return NULL;
380 }
381
382 // Retain the buffer while we're using it
383 CVPixelBufferRetain(pixelBuffer);
384
385 [ctx->delegate.frameLock unlock];
386
387 // Lock the pixel buffer
388 if (CVPixelBufferLockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly) != kCVReturnSuccess) {
389 log_error("Failed to lock pixel buffer");
390 CVPixelBufferRelease(pixelBuffer);
391 return NULL;
392 }
393
394 // Get buffer info
395 size_t width = CVPixelBufferGetWidth(pixelBuffer);
396 size_t height = CVPixelBufferGetHeight(pixelBuffer);
397 size_t bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer);
398 uint8_t *baseAddress = (uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer);
399
400 if (!baseAddress) {
401 CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
402 CVPixelBufferRelease(pixelBuffer); // Release our retained reference
403 log_error("Failed to get pixel buffer base address");
404 return NULL;
405 }
406
407 // Allocate or reuse cached frame (allocated once, reused for each call)
408 // This matches the documented API contract: "Subsequent calls reuse the same buffer"
409 if (!ctx->cached_frame) {
410 ctx->cached_frame = image_new((int)width, (int)height);
411 if (!ctx->cached_frame) {
412 CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
413 CVPixelBufferRelease(pixelBuffer); // Release our retained reference
414 log_error("Failed to allocate image buffer");
415 return NULL;
416 }
417 }
418
419 image_t *img = ctx->cached_frame;
420
421 // Copy pixel data (handle potential row padding)
422 // Calculate frame size with overflow checking
423 size_t frame_size;
424 if (image_calc_rgb_size(width, height, &frame_size) != ASCIICHAT_OK) {
425 CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
426 CVPixelBufferRelease(pixelBuffer);
427 log_error("Failed to calculate frame size: width=%zu, height=%zu (would overflow)", width, height);
428 return NULL;
429 }
430
431 size_t row_size;
432 if (image_calc_rgb_size(width, 1, &row_size) != ASCIICHAT_OK) {
433 CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
434 CVPixelBufferRelease(pixelBuffer);
435 log_error("Failed to calculate row size: width=%zu (would overflow)", width);
436 return NULL;
437 }
438
439 if (bytesPerRow == row_size) {
440 // No padding, direct copy
441 memcpy(img->pixels, baseAddress, frame_size);
442 } else {
443 // Copy row by row to handle padding
444 for (size_t y = 0; y < height; y++) {
445 memcpy(&img->pixels[y * row_size], &baseAddress[y * bytesPerRow], row_size);
446 }
447 }
448
449 // Unlock the pixel buffer
450 CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
451
452 // Release our retained reference
453 CVPixelBufferRelease(pixelBuffer);
454
455 return img;
456 }
457}
458
459asciichat_error_t webcam_get_dimensions(webcam_context_t *ctx, int *width, int *height) {
460 if (!ctx || !width || !height)
461 return ERROR_INVALID_PARAM;
462
463 *width = ctx->width;
464 *height = ctx->height;
465 return ASCIICHAT_OK;
466}
467
468asciichat_error_t webcam_list_devices(webcam_device_info_t **out_devices, unsigned int *out_count) {
469 if (!out_devices || !out_count) {
470 return SET_ERRNO(ERROR_INVALID_PARAM, "webcam_list_devices: invalid parameters");
471 }
472
473 *out_devices = NULL;
474 *out_count = 0;
475
476 @autoreleasepool {
477 // Get all video capture devices using the helper that handles deprecated types
478 NSArray *deviceTypes = getSupportedDeviceTypes();
479 AVCaptureDeviceDiscoverySession *discoverySession =
480 [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:deviceTypes
481 mediaType:AVMediaTypeVideo
482 position:AVCaptureDevicePositionUnspecified];
483
484 NSArray<AVCaptureDevice *> *av_devices = discoverySession.devices;
485 NSUInteger device_count = [av_devices count];
486
487 if (device_count == 0) {
488 // No devices found - not an error
489 return ASCIICHAT_OK;
490 }
491
492 // Allocate device array
493 webcam_device_info_t *devices =
494 SAFE_CALLOC((size_t)device_count, sizeof(webcam_device_info_t), webcam_device_info_t *);
495 if (!devices) {
496 return SET_ERRNO(ERROR_MEMORY, "Failed to allocate device info array");
497 }
498
499 // Populate device info
500 for (NSUInteger i = 0; i < device_count; i++) {
501 AVCaptureDevice *device = av_devices[i];
502 devices[i].index = (unsigned int)i;
503
504 const char *name = [[device localizedName] UTF8String];
505 if (name) {
506 SAFE_STRNCPY(devices[i].name, name, WEBCAM_DEVICE_NAME_MAX);
507 } else {
508 SAFE_STRNCPY(devices[i].name, "<Unknown>", WEBCAM_DEVICE_NAME_MAX);
509 }
510 }
511
512 *out_devices = devices;
513 *out_count = (unsigned int)device_count;
514 }
515
516 return ASCIICHAT_OK;
517}
518
519void webcam_free_device_list(webcam_device_info_t *devices) {
520 SAFE_FREE(devices);
521}
522
523#endif // __APPLE__
asciichat_error_t image_calc_rgb_size(size_t width, size_t height, size_t *out_size)
Definition util/image.c:54
void image_destroy(image_t *p)
Definition video/image.c:85
image_t * image_new(size_t width, size_t height)
Definition video/image.c:36
asciichat_error_t webcam_init_context(webcam_context_t **ctx, unsigned short int device_index)
void webcam_free_device_list(webcam_device_info_t *devices)
void webcam_flush_context(webcam_context_t *ctx)
asciichat_error_t webcam_list_devices(webcam_device_info_t **out_devices, unsigned int *out_count)
asciichat_error_t webcam_get_dimensions(webcam_context_t *ctx, int *width, int *height)
image_t * webcam_read_context(webcam_context_t *ctx)
void webcam_cleanup_context(webcam_context_t *ctx)