Created
June 15, 2026 22:07
-
-
Save osnr/c17d90ec30e5cedf933110f12ed6c661 to your computer and use it in GitHub Desktop.
camera/macos.folk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # camera/macos.folk -- | |
| # | |
| # Hardware interface with webcams on macOS (AVFoundation). | |
| if {$::tcl_platform(os) ne "darwin"} { return } | |
| set enumCc [C] | |
| $enumCc cflags -x objective-c | |
| $enumCc cflags -Wno-deprecated-declarations | |
| $enumCc endcflags -framework AVFoundation -framework Foundation -framework CoreMedia | |
| $enumCc code { | |
| #import <AVFoundation/AVFoundation.h> | |
| #import <CoreMedia/CoreMedia.h> | |
| #import <Foundation/Foundation.h> | |
| } | |
| $enumCc proc listDevices {} Jim_Obj* { | |
| Jim_Obj* resultList = Jim_NewListObj(interp, NULL, 0); | |
| @autoreleasepool { | |
| NSArray* devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo]; | |
| for (AVCaptureDevice* device in devices) { | |
| // Group formats by (width, height), collecting unique max frame rates. | |
| // dimOrder preserves first-seen ordering (AVFoundation lists largest first). | |
| NSMutableDictionary* fpsSetByDim = [NSMutableDictionary dictionary]; | |
| NSMutableArray* dimOrder = [NSMutableArray array]; | |
| for (AVCaptureDeviceFormat* fmt in device.formats) { | |
| CMVideoDimensions dims = | |
| CMVideoFormatDescriptionGetDimensions(fmt.formatDescription); | |
| NSString* key = | |
| [NSString stringWithFormat:@"%d %d", dims.width, dims.height]; | |
| NSMutableSet* fpsSet = fpsSetByDim[key]; | |
| if (!fpsSet) { | |
| fpsSet = [NSMutableSet set]; | |
| fpsSetByDim[key] = fpsSet; | |
| [dimOrder addObject:key]; | |
| } | |
| for (AVFrameRateRange* range in fmt.videoSupportedFrameRateRanges) { | |
| [fpsSet addObject:@(range.maxFrameRate)]; | |
| } | |
| } | |
| // Build resolutions list (fps sorted descending). | |
| Jim_Obj* resList = Jim_NewListObj(interp, NULL, 0); | |
| for (NSString* key in dimOrder) { | |
| int w, h; | |
| sscanf([key UTF8String], "%d %d", &w, &h); | |
| NSArray* sortedFps = | |
| [[[fpsSetByDim[key] allObjects] | |
| sortedArrayUsingSelector:@selector(compare:)] reversedArray]; | |
| Jim_Obj* fpsList = Jim_NewListObj(interp, NULL, 0); | |
| for (NSNumber* fps in sortedFps) { | |
| Jim_ListAppendElement(interp, fpsList, | |
| Jim_NewDoubleObj(interp, [fps doubleValue])); | |
| } | |
| int fpsLen; | |
| const char* fpsStr = Jim_GetString(fpsList, &fpsLen); | |
| Jim_Obj* resDict = Jim_ObjPrintf( | |
| "width %d height %d framerates {%s}", w, h, fpsStr); | |
| Jim_ListAppendElement(interp, resList, resDict); | |
| } | |
| int resLen; | |
| const char* resStr = Jim_GetString(resList, &resLen); | |
| Jim_Obj* devDict = Jim_ObjPrintf( | |
| "uniqueID {%s} name {%s} resolutions {%s}", | |
| [device.uniqueID UTF8String], | |
| [device.localizedName UTF8String], | |
| resStr); | |
| Jim_ListAppendElement(interp, resultList, devDict); | |
| } | |
| } | |
| return resultList; | |
| } | |
| $enumCc cflags -Wall | |
| set enumLib [$enumCc compile] | |
| foreach device [$enumLib listDevices] { | |
| set uniqueID [dict get $device uniqueID] | |
| set name [dict get $device name] | |
| set resolutions [dict get $device resolutions] | |
| # We capture as BGRA and compress to JPEG, so advertise MJPG so the | |
| # setup UI shows resolutions and frame-rate choices. | |
| set formats [list [list fourcc MJPG description {Motion-JPEG} \ | |
| resolutions $resolutions]] | |
| Claim $::thisNode has camera "avf:$uniqueID" with card $name formats $formats | |
| } | |
| When the image library is /imageLib/ &\ | |
| the jpeg library is /jpegLib/ { | |
| set camc [C] | |
| $camc extend $imageLib | |
| $camc cflags -x objective-c | |
| $camc cflags -Wno-deprecated-declarations | |
| $camc endcflags -framework AVFoundation -framework Foundation -framework CoreMedia -framework CoreVideo -lturbojpeg | |
| $camc include <stdlib.h> | |
| $camc include <string.h> | |
| $camc include <pthread.h> | |
| $camc include <stdint.h> | |
| $camc include <stdbool.h> | |
| $camc struct Jpeg { | |
| uint8_t* start; | |
| size_t length; | |
| } | |
| $camc code { | |
| #undef EXTERN | |
| #include <turbojpeg.h> | |
| #import <AVFoundation/AVFoundation.h> | |
| #import <CoreMedia/CoreMedia.h> | |
| #import <CoreVideo/CoreVideo.h> | |
| #import <Foundation/Foundation.h> | |
| typedef struct Camera Camera; | |
| struct Camera { | |
| AVCaptureSession* session; | |
| id delegate; | |
| uint32_t requestedWidth; | |
| uint32_t requestedHeight; | |
| pthread_mutex_t mutex; | |
| pthread_cond_t cond; | |
| uint8_t* frameData; | |
| uint32_t frameWidth; | |
| uint32_t frameHeight; | |
| uint32_t frameBytesPerRow; | |
| bool hasFrame; | |
| bool closed; | |
| }; | |
| @interface FolkCameraDelegate : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate> | |
| @property (nonatomic, assign) Camera* cam; | |
| @end | |
| @implementation FolkCameraDelegate | |
| - (void)captureOutput:(AVCaptureOutput *)output | |
| didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer | |
| fromConnection:(AVCaptureConnection *)connection { | |
| Camera* cam = self.cam; | |
| CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); | |
| CVPixelBufferLockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly); | |
| size_t width = CVPixelBufferGetWidth(imageBuffer); | |
| size_t height = CVPixelBufferGetHeight(imageBuffer); | |
| size_t bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer); | |
| uint8_t* data = (uint8_t*)CVPixelBufferGetBaseAddress(imageBuffer); | |
| size_t len = bytesPerRow * height; | |
| pthread_mutex_lock(&cam->mutex); | |
| if (!cam->closed) { | |
| if (cam->frameData) free(cam->frameData); | |
| cam->frameData = malloc(len); | |
| memcpy(cam->frameData, data, len); | |
| cam->frameWidth = (uint32_t)width; | |
| cam->frameHeight = (uint32_t)height; | |
| cam->frameBytesPerRow = (uint32_t)bytesPerRow; | |
| cam->hasFrame = true; | |
| pthread_cond_signal(&cam->cond); | |
| } | |
| pthread_mutex_unlock(&cam->mutex); | |
| CVPixelBufferUnlockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly); | |
| } | |
| @end | |
| static Jpeg bgra_to_jpeg(uint8_t* data, uint32_t width, uint32_t height, uint32_t bytesPerRow) { | |
| tjhandle handle = tjInitCompress(); | |
| if (!handle) { | |
| FOLK_ERROR("camera/macos: Failed to init JPEG compressor"); | |
| } | |
| unsigned char* jpegBuf = NULL; | |
| unsigned long jpegSize = 0; | |
| int ret = tjCompress2(handle, data, width, bytesPerRow, height, | |
| TJPF_BGRA, &jpegBuf, &jpegSize, | |
| TJSAMP_420, 85, TJFLAG_FASTDCT); | |
| tjDestroy(handle); | |
| if (ret != 0) { | |
| FOLK_ERROR("camera/macos: JPEG compression failed: %s", tjGetErrorStr()); | |
| } | |
| uint8_t* outBuf = malloc(jpegSize); | |
| memcpy(outBuf, jpegBuf, jpegSize); | |
| tjFree(jpegBuf); | |
| return (Jpeg){ .start = outBuf, .length = jpegSize }; | |
| } | |
| } | |
| $camc proc cameraOpen {char* deviceID int width int height} Camera* { | |
| Camera* cam = calloc(1, sizeof(Camera)); | |
| cam->requestedWidth = width; | |
| cam->requestedHeight = height; | |
| pthread_mutex_init(&cam->mutex, NULL); | |
| pthread_cond_init(&cam->cond, NULL); | |
| @autoreleasepool { | |
| // Check and request camera permission. | |
| AVAuthorizationStatus authStatus = | |
| [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]; | |
| if (authStatus == AVAuthorizationStatusNotDetermined) { | |
| dispatch_semaphore_t sem = dispatch_semaphore_create(0); | |
| [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo | |
| completionHandler:^(BOOL granted) { | |
| dispatch_semaphore_signal(sem); | |
| }]; | |
| dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); | |
| dispatch_release(sem); | |
| authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]; | |
| } | |
| if (authStatus != AVAuthorizationStatusAuthorized) { | |
| pthread_mutex_destroy(&cam->mutex); | |
| pthread_cond_destroy(&cam->cond); | |
| free(cam); | |
| FOLK_ERROR("camera/macos: Camera not authorized. Grant access in System Settings > Privacy > Camera."); | |
| } | |
| // Find device by uniqueID or localizedName. | |
| NSString* deviceIDStr = [NSString stringWithUTF8String:deviceID]; | |
| AVCaptureDevice* device = nil; | |
| NSArray* devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo]; | |
| for (AVCaptureDevice* d in devices) { | |
| if ([d.uniqueID isEqualToString:deviceIDStr] || | |
| [d.localizedName isEqualToString:deviceIDStr]) { | |
| device = d; | |
| break; | |
| } | |
| } | |
| if (device == nil) { | |
| device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; | |
| } | |
| if (device == nil) { | |
| pthread_mutex_destroy(&cam->mutex); | |
| pthread_cond_destroy(&cam->cond); | |
| free(cam); | |
| FOLK_ERROR("camera/macos: No camera found for '%s'", deviceID); | |
| } | |
| NSError* error = nil; | |
| AVCaptureDeviceInput* input = | |
| [AVCaptureDeviceInput deviceInputWithDevice:device error:&error]; | |
| if (input == nil) { | |
| pthread_mutex_destroy(&cam->mutex); | |
| pthread_cond_destroy(&cam->cond); | |
| free(cam); | |
| FOLK_ERROR("camera/macos: Failed to create input: %s", | |
| [[error localizedDescription] UTF8String]); | |
| } | |
| cam->session = [[AVCaptureSession alloc] init]; | |
| if (width <= 640 && height <= 480) { | |
| cam->session.sessionPreset = AVCaptureSessionPreset640x480; | |
| } else if (width <= 1280 && height <= 720) { | |
| cam->session.sessionPreset = AVCaptureSessionPreset1280x720; | |
| } else { | |
| cam->session.sessionPreset = AVCaptureSessionPreset1920x1080; | |
| } | |
| if (![cam->session canAddInput:input]) { | |
| [cam->session release]; | |
| pthread_mutex_destroy(&cam->mutex); | |
| pthread_cond_destroy(&cam->cond); | |
| free(cam); | |
| FOLK_ERROR("camera/macos: Cannot add input to session"); | |
| } | |
| [cam->session addInput:input]; | |
| AVCaptureVideoDataOutput* output = [[AVCaptureVideoDataOutput alloc] init]; | |
| output.videoSettings = @{ | |
| (NSString*)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA) | |
| }; | |
| output.alwaysDiscardsLateVideoFrames = YES; | |
| FolkCameraDelegate* delegate = [[FolkCameraDelegate alloc] init]; | |
| delegate.cam = cam; | |
| cam->delegate = delegate; | |
| dispatch_queue_t queue = | |
| dispatch_queue_create("folk.camera.macos", DISPATCH_QUEUE_SERIAL); | |
| [output setSampleBufferDelegate:delegate queue:queue]; | |
| dispatch_release(queue); | |
| if (![cam->session canAddOutput:output]) { | |
| [cam->session release]; | |
| [delegate release]; | |
| [output release]; | |
| pthread_mutex_destroy(&cam->mutex); | |
| pthread_cond_destroy(&cam->cond); | |
| free(cam); | |
| FOLK_ERROR("camera/macos: Cannot add output to session"); | |
| } | |
| [cam->session addOutput:output]; | |
| [output release]; | |
| } | |
| printf("camera/macos: Opened '%s'\n", deviceID); | |
| return cam; | |
| } | |
| $camc proc cameraStart {Camera* cam} void { | |
| [cam->session startRunning]; | |
| } | |
| $camc proc cameraClose {Camera* cam} void { | |
| pthread_mutex_lock(&cam->mutex); | |
| cam->closed = true; | |
| pthread_cond_broadcast(&cam->cond); | |
| pthread_mutex_unlock(&cam->mutex); | |
| [cam->session stopRunning]; | |
| [cam->session release]; | |
| cam->session = nil; | |
| [cam->delegate release]; | |
| cam->delegate = nil; | |
| pthread_mutex_lock(&cam->mutex); | |
| if (cam->frameData) { | |
| free(cam->frameData); | |
| cam->frameData = NULL; | |
| } | |
| pthread_mutex_unlock(&cam->mutex); | |
| pthread_mutex_destroy(&cam->mutex); | |
| pthread_cond_destroy(&cam->cond); | |
| free(cam); | |
| } | |
| $camc proc cameraFrameJpeg {Camera* cam} Jpeg { | |
| pthread_mutex_lock(&cam->mutex); | |
| while (!cam->hasFrame && !cam->closed) { | |
| pthread_cond_wait(&cam->cond, &cam->mutex); | |
| } | |
| if (cam->closed) { | |
| pthread_mutex_unlock(&cam->mutex); | |
| FOLK_ERROR("camera/macos: Camera closed"); | |
| } | |
| uint8_t* frameData = cam->frameData; | |
| uint32_t frameWidth = cam->frameWidth; | |
| uint32_t frameHeight = cam->frameHeight; | |
| uint32_t frameBytesPerRow = cam->frameBytesPerRow; | |
| cam->frameData = NULL; | |
| cam->hasFrame = false; | |
| pthread_mutex_unlock(&cam->mutex); | |
| Jpeg jpeg = bgra_to_jpeg(frameData, frameWidth, frameHeight, frameBytesPerRow); | |
| free(frameData); | |
| return jpeg; | |
| } | |
| $camc proc jpegFree {Jpeg jpeg} void { | |
| free(jpeg.start); | |
| } | |
| $camc cflags -Wall | |
| set camLib [$camc compile] | |
| When /someone/ wishes $::thisNode uses camera /camera/ with /...options/ { | |
| if {![string match "avf:*" $camera]} { return } | |
| set deviceID [string range $camera 4 end] | |
| set width [dict get $options width] | |
| set height [dict get $options height] | |
| if {[dict exists $options crops]} { | |
| set crops [dict get $options crops] | |
| } | |
| set camObj [$camLib cameraOpen $deviceID $width $height] | |
| puts "camera/macos: Loaded $camera -> $camObj" | |
| $camLib cameraStart $camObj | |
| # skip 5 frames for camera warmup | |
| for {set i 0} {$i < 5} {incr i} { | |
| set f [$camLib cameraFrameJpeg $camObj] | |
| $camLib jpegFree $f | |
| } | |
| try -signal { | |
| if {[info exists crops]} { | |
| for {set i 0} {$i < [llength $crops]} {incr i} { | |
| set cropPath [list $camera $i] | |
| set cropWidth [dict get [lindex $crops $i] width] | |
| set cropHeight [dict get [lindex $crops $i] height] | |
| Claim camera $cropPath has width $cropWidth height $cropHeight | |
| When camera $cropPath has jpeg frame /jpeg/ at timestamp /ts/ { | |
| set grayImage [$jpegLib jpegDecompressGray $jpeg \ | |
| $cropWidth $cropHeight \ | |
| [expr {int($ts * 1000)}]] | |
| set rgbImage [$jpegLib jpegDecompressRGB $jpeg \ | |
| $cropWidth $cropHeight \ | |
| [expr {int($ts * 1000) + 1}]] | |
| Hold! -key [list camera $cropPath gray-frame] \ | |
| Claim camera $cropPath has gray frame $grayImage at timestamp $ts \ | |
| -destructor [list $imageLib imageFree $grayImage] | |
| Hold! -key [list camera $cropPath frame] \ | |
| Claim camera $cropPath has frame $rgbImage at timestamp $ts \ | |
| -destructor [list $imageLib imageFree $rgbImage] | |
| } | |
| } | |
| } else { | |
| Claim camera $camera has width $width height $height | |
| When camera $camera has jpeg frame /jpeg/ at timestamp /ts/ { | |
| set grayImage [$jpegLib jpegDecompressGray $jpeg \ | |
| $width $height \ | |
| [expr {int($ts * 1000)}]] | |
| set rgbImage [$jpegLib jpegDecompressRGB $jpeg \ | |
| $width $height \ | |
| [expr {int($ts * 1000) + 1}]] | |
| Hold! -key [list camera $camera gray-frame] \ | |
| Claim camera $camera has gray frame $grayImage at timestamp $ts \ | |
| -destructor [list $imageLib imageFree $grayImage] | |
| Hold! -key [list camera $camera frame] \ | |
| Claim camera $camera has frame $rgbImage at timestamp $ts \ | |
| -destructor [list $imageLib imageFree $rgbImage] | |
| } | |
| } | |
| while true { | |
| tracy zoneBegin | |
| set ms [clock milliseconds] | |
| set jpeg [$camLib cameraFrameJpeg $camObj] | |
| set timestamp [expr {$ms / 1000.0}] | |
| tracy zoneName "camera/macos: $timestamp" | |
| if {[info exists crops]} { | |
| for {set i 0} {$i < [llength $crops]} {incr i} { | |
| set crop [lindex $crops $i] | |
| set cropPath [list $camera $i] | |
| set croppedJpeg [$jpegLib jpegSubimage $jpeg \ | |
| [dict get $crop x] \ | |
| [dict get $crop y] \ | |
| [dict get $crop width] \ | |
| [dict get $crop height]] | |
| Hold! -key [list camera $cropPath jpeg] \ | |
| Claim camera $cropPath has jpeg frame $croppedJpeg at timestamp $timestamp \ | |
| -destructor [list $camLib jpegFree $croppedJpeg] | |
| } | |
| } | |
| Hold! -key [list camera $camera jpeg] \ | |
| Claim camera $camera has jpeg frame $jpeg at timestamp $timestamp \ | |
| -destructor [list $camLib jpegFree $jpeg] | |
| tracy zoneEnd | |
| } | |
| } on error e { | |
| puts stderr "camera/macos: Error $e" | |
| Hold! -key [list camera-error $camera] \ | |
| Claim camera $camera failed with error $e | |
| } on signal sig { | |
| puts stderr "camera/macos: Signal $sig" | |
| } finally { | |
| puts "camera/macos: Close $camObj" | |
| $camLib cameraClose $camObj | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment