serve-sim-sjchmiela 0.1.34

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,942 @@
1
+ // SimCameraHelper — host-side source manager for serve-sim's simulator
2
+ // camera feed. Owns a POSIX shared-memory region the injected dylib mmaps,
3
+ // and writes BGRA frames into it from one of several swappable sources:
4
+ //
5
+ // - placeholder : programmatically rendered moving frames (default)
6
+ // - webcam : live AVCaptureDevice (front Mac camera, Continuity, …)
7
+ // - image : a single PNG/JPEG, written once
8
+ //
9
+ // A UNIX-domain control socket lets the CLI (and the in-page Camera tool)
10
+ // switch sources at runtime without relaunching the simulator app — the
11
+ // dylib just keeps reading whatever frames the helper writes.
12
+ //
13
+ // Command line:
14
+ // serve-sim-camera-helper --shm <name> [--socket <path>]
15
+ // [--source placeholder|webcam|image]
16
+ // [--arg <value>] # webcam name / image path
17
+ // [--width 1280] [--height 720]
18
+ // serve-sim-camera-helper --list
19
+ //
20
+ // Control protocol (line-delimited JSON over AF_UNIX, each line one command):
21
+ // {"action":"switch","source":"webcam","arg":"MacBook Pro Camera"}
22
+ // {"action":"switch","source":"placeholder"}
23
+ // {"action":"status"} -> server replies one JSON line
24
+ // {"action":"shutdown"}
25
+
26
+ #import <AVFoundation/AVFoundation.h>
27
+ #import <CoreMedia/CoreMedia.h>
28
+ #import <CoreVideo/CoreVideo.h>
29
+ #import <CoreImage/CoreImage.h>
30
+ #import <Accelerate/Accelerate.h>
31
+ #import <ImageIO/ImageIO.h>
32
+ #import <IOSurface/IOSurface.h>
33
+
34
+ #include <fcntl.h>
35
+ #include <signal.h>
36
+ #include <sys/mman.h>
37
+ #include <sys/socket.h>
38
+ #include <sys/stat.h>
39
+ #include <sys/un.h>
40
+ #include <unistd.h>
41
+ #include <stdatomic.h>
42
+ #include <mach/mach_time.h>
43
+ #include "../SimCameraInjector/include/SimCamShared.h"
44
+
45
+ #pragma mark - Globals (shm + writer)
46
+
47
+ static SimCamShmHeader *gHeader = NULL;
48
+ static SimCamSurfaceTable *gSurfaceTable = NULL;
49
+ static IOSurfaceRef gSurfaces[SIMCAM_SURFACE_RING];
50
+ static uint32_t gWriteIndex = 0; // last ring slot rendered into
51
+ static uint32_t gWidth = SIMCAM_DEFAULT_WIDTH;
52
+ static uint32_t gHeight = SIMCAM_DEFAULT_HEIGHT;
53
+ static const char *gShmName = NULL;
54
+ static volatile sig_atomic_t gShouldExit = 0;
55
+ static atomic_uint_fast64_t gFrameSeq = 0;
56
+
57
+ static uint64_t MachAbsToNs(uint64_t t) {
58
+ static mach_timebase_info_data_t tb = {0,0};
59
+ if (tb.denom == 0) mach_timebase_info(&tb);
60
+ return t * tb.numer / tb.denom;
61
+ }
62
+
63
+ static void HandleSig(int sig) { (void)sig; gShouldExit = 1; }
64
+
65
+ // Forward decls — definitions live near OpenShm so the control-socket
66
+ // handler (which sits earlier in this file post-refactor) can call them.
67
+ static uint8_t ParseMirrorCode(NSString *mode);
68
+ static NSString *MirrorName(uint8_t code);
69
+
70
+ // Publish a fully-prepared BGRA frame (gWidth x gHeight, packed at gWidth*4
71
+ // bytes per row) into the next free ring surface. Writers MUST go through this
72
+ // so latestIndex/frameSeq stay coherent for the dylib's tear-detection check.
73
+ static void PublishFrame(const uint8_t *bgra) {
74
+ if (!gHeader || !gSurfaceTable || !bgra) return;
75
+ uint32_t count = gSurfaceTable->surfaceCount;
76
+ if (count == 0) return;
77
+
78
+ // Render into a surface the reader isn't holding and isn't the one it last
79
+ // published, so an in-flight frame is never overwritten mid-read.
80
+ uint32_t latest = gSurfaceTable->latestIndex;
81
+ uint32_t idx = gWriteIndex;
82
+ BOOL found = NO;
83
+ for (uint32_t tries = 0; tries < count; tries++) {
84
+ idx = (idx + 1) % count;
85
+ if (idx == latest) continue;
86
+ if (!IOSurfaceIsInUse(gSurfaces[idx])) {
87
+ found = YES;
88
+ break;
89
+ }
90
+ }
91
+ if (!found) return;
92
+ gWriteIndex = idx;
93
+
94
+ IOSurfaceRef surface = gSurfaces[idx];
95
+ IOSurfaceLock(surface, 0, NULL);
96
+ uint8_t *dst = (uint8_t *)IOSurfaceGetBaseAddress(surface);
97
+ size_t dstStride = IOSurfaceGetBytesPerRow(surface);
98
+ size_t srcStride = (size_t)gWidth * 4;
99
+ if (dstStride == srcStride) {
100
+ memcpy(dst, bgra, srcStride * gHeight);
101
+ } else {
102
+ for (uint32_t y = 0; y < gHeight; y++) {
103
+ memcpy(dst + (size_t)y * dstStride, bgra + (size_t)y * srcStride, srcStride);
104
+ }
105
+ }
106
+ IOSurfaceUnlock(surface, 0, NULL);
107
+
108
+ gSurfaceTable->latestIndex = idx;
109
+ gHeader->timestampNs = MachAbsToNs(mach_absolute_time());
110
+ atomic_thread_fence(memory_order_release);
111
+ uint64_t next = atomic_fetch_add(&gFrameSeq, 1) + 1;
112
+ atomic_store_explicit(&gHeader->frameSeq, next, memory_order_release);
113
+ }
114
+
115
+ #pragma mark - Source pipeline (start / stop / switch)
116
+
117
+ typedef NS_ENUM(NSInteger, SimCamSourceKind) {
118
+ SimCamSourceNone = 0,
119
+ SimCamSourcePlaceholder,
120
+ SimCamSourceWebcam,
121
+ SimCamSourceImage,
122
+ SimCamSourceVideo,
123
+ };
124
+
125
+ static SimCamSourceKind gActiveSource = SimCamSourceNone;
126
+ static dispatch_queue_t gSourceQueue; // serial — owns source lifecycle
127
+ static dispatch_source_t gPlaceholderTimer;
128
+ static AVCaptureSession *gWebcamSession;
129
+ static SimCamSourceKind gPendingSource; // for status reporting
130
+ static NSString *gActiveArg = nil; // selected camera name, image path
131
+
132
+ @interface SimCamWebcamWriter : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate>
133
+ @end
134
+
135
+ @implementation SimCamWebcamWriter
136
+ - (void)captureOutput:(AVCaptureOutput *)out
137
+ didOutputSampleBuffer:(CMSampleBufferRef)sb
138
+ fromConnection:(AVCaptureConnection *)conn {
139
+ CVImageBufferRef pb = CMSampleBufferGetImageBuffer(sb);
140
+ if (!pb || !gHeader) return;
141
+ if (CVPixelBufferGetPixelFormatType(pb) != kCVPixelFormatType_32BGRA) return;
142
+ CVPixelBufferLockBaseAddress(pb, kCVPixelBufferLock_ReadOnly);
143
+ size_t srcW = CVPixelBufferGetWidth(pb);
144
+ size_t srcH = CVPixelBufferGetHeight(pb);
145
+ size_t srcStride = CVPixelBufferGetBytesPerRow(pb);
146
+ void *src = CVPixelBufferGetBaseAddress(pb);
147
+ static uint8_t *scratch = NULL;
148
+ static size_t scratchSize = 0;
149
+ size_t need = (size_t)gWidth * gHeight * 4;
150
+ if (scratchSize < need) {
151
+ free(scratch);
152
+ scratch = malloc(need);
153
+ scratchSize = need;
154
+ }
155
+ vImage_Buffer s = { src, srcH, srcW, srcStride };
156
+ vImage_Buffer d = { scratch, gHeight, gWidth, (size_t)gWidth * 4 };
157
+ vImage_Error verr = vImageScale_ARGB8888(&s, &d, NULL, kvImageHighQualityResampling);
158
+ CVPixelBufferUnlockBaseAddress(pb, kCVPixelBufferLock_ReadOnly);
159
+ if (verr == kvImageNoError) PublishFrame(scratch);
160
+ }
161
+ @end
162
+
163
+ static SimCamWebcamWriter *gWebcamWriter = nil;
164
+
165
+ #pragma mark Placeholder source — Remotion-style "blueprint" grid
166
+
167
+ // Visual parity with apps/editor/src/backgrounds/BlueprintBackground.tsx in
168
+ // the device-frames repo: a fixed #019EFF→#0168D4 vertical gradient, a major
169
+ // grid every 120px with minor subdivisions every 24px, and tiny animated
170
+ // cross markers at major intersections that rotate + scale-pulse. All of the
171
+ // static layers (gradient + grid) are rasterized once into a CGImage and
172
+ // blitted each frame; only the crosses are redrawn live.
173
+
174
+ #define BP_GRID_MAJOR 120.0
175
+ #define BP_GRID_MINOR_DIV 5
176
+ #define BP_CROSS_SIZE 7.0
177
+ #define BP_CROSS_STROKE 4.0
178
+
179
+ static CGImageRef gBPBackground = NULL; // cached gradient + grid
180
+ static uint32_t gBPCachedW = 0;
181
+ static uint32_t gBPCachedH = 0;
182
+
183
+ // Match the JS seededRandom in BlueprintBackground.tsx so cross timings line
184
+ // up with the Remotion reference. (The original is `sin(seed*438.8) * K`
185
+ // since 127.1+311.7 = 438.8 and both factors multiply the same `seed`.)
186
+ static inline double BPSeededRandom(double seed) {
187
+ double x = sin(seed * 438.8) * 43758.5453;
188
+ return x - floor(x);
189
+ }
190
+
191
+ static CGImageRef BuildBlueprintBackground(uint32_t w, uint32_t h) {
192
+ size_t bpr = (size_t)w * 4;
193
+ CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
194
+ CGContextRef ctx = CGBitmapContextCreate(NULL, w, h, 8, bpr, cs,
195
+ kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little);
196
+ if (!ctx) { CGColorSpaceRelease(cs); return NULL; }
197
+
198
+ // Vertical gradient #019EFF → #0168D4.
199
+ CGFloat colors[8] = {
200
+ 0x01/255.0, 0x9E/255.0, 0xFF/255.0, 1.0,
201
+ 0x01/255.0, 0x68/255.0, 0xD4/255.0, 1.0,
202
+ };
203
+ CGGradientRef grad = CGGradientCreateWithColorComponents(cs, colors,
204
+ (CGFloat[]){0, 1}, 2);
205
+ CGContextDrawLinearGradient(ctx, grad,
206
+ CGPointMake(w/2.0, h), CGPointMake(w/2.0, 0), 0);
207
+ CGGradientRelease(grad);
208
+
209
+ double minor = BP_GRID_MAJOR / (double)BP_GRID_MINOR_DIV;
210
+
211
+ // Minor grid: stroke 0.5, white α=0.08.
212
+ CGContextSetRGBStrokeColor(ctx, 1, 1, 1, 0.08);
213
+ CGContextSetLineWidth(ctx, 0.5);
214
+ CGContextBeginPath(ctx);
215
+ for (double y = 0; y <= h + minor; y += minor) {
216
+ if (fmod(y, BP_GRID_MAJOR) == 0) continue;
217
+ CGContextMoveToPoint(ctx, 0, y);
218
+ CGContextAddLineToPoint(ctx, w, y);
219
+ }
220
+ for (double x = 0; x <= w + minor; x += minor) {
221
+ if (fmod(x, BP_GRID_MAJOR) == 0) continue;
222
+ CGContextMoveToPoint(ctx, x, 0);
223
+ CGContextAddLineToPoint(ctx, x, h);
224
+ }
225
+ CGContextStrokePath(ctx);
226
+
227
+ // Major grid: stroke 1.5, white α=0.15.
228
+ CGContextSetRGBStrokeColor(ctx, 1, 1, 1, 0.15);
229
+ CGContextSetLineWidth(ctx, 1.5);
230
+ CGContextBeginPath(ctx);
231
+ for (double y = 0; y <= h + BP_GRID_MAJOR; y += BP_GRID_MAJOR) {
232
+ CGContextMoveToPoint(ctx, 0, y);
233
+ CGContextAddLineToPoint(ctx, w, y);
234
+ }
235
+ for (double x = 0; x <= w + BP_GRID_MAJOR; x += BP_GRID_MAJOR) {
236
+ CGContextMoveToPoint(ctx, x, 0);
237
+ CGContextAddLineToPoint(ctx, x, h);
238
+ }
239
+ CGContextStrokePath(ctx);
240
+
241
+ CGImageRef img = CGBitmapContextCreateImage(ctx);
242
+ CGContextRelease(ctx);
243
+ CGColorSpaceRelease(cs);
244
+ return img;
245
+ }
246
+
247
+ static void RenderPlaceholderFrame(uint8_t *out, uint64_t frameIdx) {
248
+ size_t bpr = (size_t)gWidth * 4;
249
+ CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
250
+ CGContextRef ctx = CGBitmapContextCreate(out, gWidth, gHeight, 8, bpr, cs,
251
+ kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little);
252
+ CGColorSpaceRelease(cs);
253
+ if (!ctx) return;
254
+
255
+ // Cached static background (gradient + grid). Rebuild on dimension change.
256
+ if (!gBPBackground || gBPCachedW != gWidth || gBPCachedH != gHeight) {
257
+ if (gBPBackground) CGImageRelease(gBPBackground);
258
+ gBPBackground = BuildBlueprintBackground(gWidth, gHeight);
259
+ gBPCachedW = gWidth;
260
+ gBPCachedH = gHeight;
261
+ }
262
+ if (gBPBackground) {
263
+ CGContextDrawImage(ctx, CGRectMake(0, 0, gWidth, gHeight), gBPBackground);
264
+ }
265
+
266
+ // Cross markers at every interior major intersection. Loops every 30s.
267
+ double t = fmod((double)frameIdx / 30.0, 30.0);
268
+ CGContextSetLineCap(ctx, kCGLineCapRound);
269
+ CGContextSetLineWidth(ctx, BP_CROSS_STROKE);
270
+
271
+ int seed = 0;
272
+ for (double cy = BP_GRID_MAJOR; cy < gHeight; cy += BP_GRID_MAJOR) {
273
+ for (double cx = BP_GRID_MAJOR; cx < gWidth; cx += BP_GRID_MAJOR) {
274
+ double offset = BPSeededRandom(seed) * M_PI * 2.0;
275
+ double speed = 0.15 + BPSeededRandom(seed + 1) * 0.20;
276
+ double scaleSpeed = 0.07 + BPSeededRandom(seed + 2) * 0.12;
277
+ double scalePhase = t * scaleSpeed + BPSeededRandom(seed + 3) * M_PI * 2.0;
278
+ seed++;
279
+
280
+ double raw = sin(scalePhase * M_PI * 2.0);
281
+ double scale = raw > 0 ? raw : 0; // half the cycle hidden
282
+ if (scale <= 0.001) continue; // skip invisible draws
283
+
284
+ double rotation = (t * speed + offset) * M_PI * 2.0;
285
+ double s = BP_CROSS_SIZE * scale;
286
+ double opacity = 0.3 + 0.5 * scale;
287
+
288
+ CGContextSaveGState(ctx);
289
+ CGContextTranslateCTM(ctx, cx, cy);
290
+ CGContextRotateCTM(ctx, rotation);
291
+ CGContextSetRGBStrokeColor(ctx, 1, 1, 1, 0.7 * opacity);
292
+ CGContextBeginPath(ctx);
293
+ CGContextMoveToPoint(ctx, -s, 0);
294
+ CGContextAddLineToPoint(ctx, s, 0);
295
+ CGContextMoveToPoint(ctx, 0, -s);
296
+ CGContextAddLineToPoint(ctx, 0, s);
297
+ CGContextStrokePath(ctx);
298
+ CGContextRestoreGState(ctx);
299
+ }
300
+ }
301
+
302
+ CGContextRelease(ctx);
303
+ }
304
+
305
+ static void StartPlaceholderSource(void) {
306
+ static uint8_t *buf = NULL;
307
+ size_t need = (size_t)gWidth * gHeight * 4;
308
+ if (!buf) buf = calloc(1, need);
309
+ if (!buf) {
310
+ fprintf(stderr, "[serve-sim-camera] placeholder buf alloc failed (%zu bytes)\n", need);
311
+ return;
312
+ }
313
+
314
+ __block uint64_t frameIdx = 0;
315
+ RenderPlaceholderFrame(buf, frameIdx++);
316
+ PublishFrame(buf);
317
+
318
+ gPlaceholderTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,
319
+ dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0));
320
+ uint64_t intervalNs = NSEC_PER_SEC / 30;
321
+ dispatch_source_set_timer(gPlaceholderTimer,
322
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)intervalNs), intervalNs, intervalNs / 10);
323
+ dispatch_source_set_event_handler(gPlaceholderTimer, ^{
324
+ RenderPlaceholderFrame(buf, frameIdx++);
325
+ PublishFrame(buf);
326
+ });
327
+ dispatch_resume(gPlaceholderTimer);
328
+ fprintf(stderr, "[serve-sim-camera] placeholder source running @ 30fps (%ux%u, first frame seq=%llu)\n",
329
+ gWidth, gHeight, (unsigned long long)atomic_load(&gFrameSeq));
330
+ }
331
+
332
+ static void StopPlaceholderSource(void) {
333
+ if (gPlaceholderTimer) {
334
+ dispatch_source_cancel(gPlaceholderTimer);
335
+ gPlaceholderTimer = NULL;
336
+ }
337
+ }
338
+
339
+ #pragma mark Webcam source
340
+
341
+ static AVCaptureDevice *PickWebcamDevice(NSString *idOrName) {
342
+ AVCaptureDeviceDiscoverySession *s = [AVCaptureDeviceDiscoverySession
343
+ discoverySessionWithDeviceTypes:@[
344
+ AVCaptureDeviceTypeBuiltInWideAngleCamera,
345
+ AVCaptureDeviceTypeExternal,
346
+ AVCaptureDeviceTypeContinuityCamera,
347
+ ]
348
+ mediaType:AVMediaTypeVideo
349
+ position:AVCaptureDevicePositionUnspecified];
350
+ if (!idOrName.length) {
351
+ for (AVCaptureDevice *d in s.devices)
352
+ if (d.position == AVCaptureDevicePositionFront) return d;
353
+ return s.devices.firstObject;
354
+ }
355
+ for (AVCaptureDevice *d in s.devices)
356
+ if ([d.uniqueID isEqualToString:idOrName]) return d;
357
+ for (AVCaptureDevice *d in s.devices)
358
+ if ([d.localizedName.lowercaseString containsString:idOrName.lowercaseString]) return d;
359
+ return nil;
360
+ }
361
+
362
+ static BOOL StartWebcamSource(NSString *deviceArg, NSString **err) {
363
+ AVCaptureDevice *device = PickWebcamDevice(deviceArg);
364
+ if (!device) { if (err) *err = @"no matching camera"; return NO; }
365
+ NSError *e = nil;
366
+ AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&e];
367
+ if (!input) { if (err) *err = e.localizedDescription ?: @"deviceInput failed"; return NO; }
368
+ AVCaptureSession *sess = [AVCaptureSession new];
369
+ sess.sessionPreset = AVCaptureSessionPreset1280x720;
370
+ if (![sess canAddInput:input]) { if (err) *err = @"session canAddInput=NO"; return NO; }
371
+ [sess addInput:input];
372
+ if (!gWebcamWriter) gWebcamWriter = [SimCamWebcamWriter new];
373
+ AVCaptureVideoDataOutput *out = [AVCaptureVideoDataOutput new];
374
+ out.alwaysDiscardsLateVideoFrames = YES;
375
+ out.videoSettings = @{
376
+ (id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
377
+ };
378
+ [out setSampleBufferDelegate:gWebcamWriter
379
+ queue:dispatch_queue_create("simcam.helper.webcam",
380
+ DISPATCH_QUEUE_SERIAL)];
381
+ if (![sess canAddOutput:out]) { if (err) *err = @"session canAddOutput=NO"; return NO; }
382
+ [sess addOutput:out];
383
+ [sess startRunning];
384
+ gWebcamSession = sess;
385
+ fprintf(stderr, "[serve-sim-camera] webcam → %s\n", device.localizedName.UTF8String);
386
+ return YES;
387
+ }
388
+
389
+ static void StopWebcamSource(void) {
390
+ if (gWebcamSession) {
391
+ [gWebcamSession stopRunning];
392
+ gWebcamSession = nil;
393
+ }
394
+ }
395
+
396
+ #pragma mark Image source
397
+
398
+ static BOOL StartImageSource(NSString *path, NSString **err) {
399
+ if (!path.length) { if (err) *err = @"image source needs a path"; return NO; }
400
+ CGImageSourceRef src = CGImageSourceCreateWithURL(
401
+ (__bridge CFURLRef)[NSURL fileURLWithPath:path], NULL);
402
+ if (!src) { if (err) *err = @"could not open image"; return NO; }
403
+ CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL);
404
+ CFRelease(src);
405
+ if (!img) { if (err) *err = @"could not decode image"; return NO; }
406
+
407
+ size_t bpr = (size_t)gWidth * 4;
408
+ uint8_t *buf = calloc(1, bpr * gHeight);
409
+ CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
410
+ CGContextRef ctx = CGBitmapContextCreate(buf, gWidth, gHeight, 8, bpr, cs,
411
+ kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little);
412
+ CGColorSpaceRelease(cs);
413
+ size_t iw = CGImageGetWidth(img), ih = CGImageGetHeight(img);
414
+ double sx = (double)gWidth / iw, sy = (double)gHeight / ih;
415
+ // Aspect-fit file sources so the full source frame remains visible.
416
+ double s = MIN(sx, sy);
417
+ double dw = iw * s, dh = ih * s;
418
+ CGContextDrawImage(ctx, CGRectMake((gWidth - dw)/2.0, (gHeight - dh)/2.0, dw, dh), img);
419
+ CGContextRelease(ctx);
420
+ CGImageRelease(img);
421
+
422
+ PublishFrame(buf);
423
+ free(buf);
424
+ fprintf(stderr, "[serve-sim-camera] image → %s\n", path.UTF8String);
425
+ return YES;
426
+ }
427
+
428
+ static void StopImageSource(void) {
429
+ // Nothing live; the published frame stays in shm until next source overwrites.
430
+ }
431
+
432
+ #pragma mark Video source (looping playback via AVAssetReader)
433
+
434
+ // Looping AVAsset playback at native FPS. Frames are decoded as BGRA on a
435
+ // background queue, scaled with vImage into the shm buffer, then paced with
436
+ // `clock_nanosleep` against the track's presentation timestamps so playback
437
+ // runs at real time. When the reader hits AVAssetReaderStatusCompleted we
438
+ // recreate it and reset the wall-clock anchor so the loop boundary is
439
+ // seamless.
440
+
441
+ static dispatch_queue_t gVideoQueue;
442
+ static atomic_bool gVideoCancelled = false;
443
+ static dispatch_semaphore_t gVideoStopped; // signaled when the loop exits
444
+
445
+ static AVAssetReaderTrackOutput *MakeVideoOutput(AVAssetReader **outReader,
446
+ AVAssetTrack *track,
447
+ NSString **errOut) {
448
+ NSError *e = nil;
449
+ AVAssetReader *reader = [AVAssetReader assetReaderWithAsset:track.asset error:&e];
450
+ if (!reader) {
451
+ if (errOut) *errOut = e.localizedDescription ?: @"AVAssetReader init failed";
452
+ return nil;
453
+ }
454
+ AVAssetReaderTrackOutput *out = [AVAssetReaderTrackOutput
455
+ assetReaderTrackOutputWithTrack:track
456
+ outputSettings:@{
457
+ (id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
458
+ (id)kCVPixelBufferIOSurfacePropertiesKey: @{},
459
+ }];
460
+ out.alwaysCopiesSampleData = NO;
461
+ if (![reader canAddOutput:out]) {
462
+ if (errOut) *errOut = @"reader rejected BGRA output";
463
+ return nil;
464
+ }
465
+ [reader addOutput:out];
466
+ if (![reader startReading]) {
467
+ if (errOut) *errOut = reader.error.localizedDescription ?: @"reader failed to start";
468
+ return nil;
469
+ }
470
+ *outReader = reader;
471
+ return out;
472
+ }
473
+
474
+ // Aspect-fit a source pixel buffer into a transient BGRA buffer sized to
475
+ // the shm region. We allocate once per call so the caller is free to free
476
+ // the result without worrying about lifetime sharing.
477
+ static uint8_t *RenderPixelBufferToShmSize(CVPixelBufferRef pb) {
478
+ size_t srcW = CVPixelBufferGetWidth(pb);
479
+ size_t srcH = CVPixelBufferGetHeight(pb);
480
+ if (srcW == 0 || srcH == 0) return NULL;
481
+ CVPixelBufferLockBaseAddress(pb, kCVPixelBufferLock_ReadOnly);
482
+ uint8_t *src = CVPixelBufferGetBaseAddress(pb);
483
+ size_t srcBPR = CVPixelBufferGetBytesPerRow(pb);
484
+ if (!src) {
485
+ CVPixelBufferUnlockBaseAddress(pb, kCVPixelBufferLock_ReadOnly);
486
+ return NULL;
487
+ }
488
+
489
+ size_t bpr = (size_t)gWidth * 4;
490
+ uint8_t *out = calloc(1, bpr * gHeight);
491
+ CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
492
+ CGContextRef ctx = CGBitmapContextCreate(out, gWidth, gHeight, 8, bpr, cs,
493
+ kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little);
494
+ CGColorSpaceRelease(cs);
495
+
496
+ // Wrap the source pixels as a CGImage we can hand to CoreGraphics.
497
+ CGDataProviderRef dp = CGDataProviderCreateWithData(NULL, src, srcBPR * srcH, NULL);
498
+ CGColorSpaceRef imgCs = CGColorSpaceCreateDeviceRGB();
499
+ CGImageRef img = CGImageCreate(srcW, srcH, 8, 32, srcBPR, imgCs,
500
+ kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little,
501
+ dp, NULL, false, kCGRenderingIntentDefault);
502
+ CGColorSpaceRelease(imgCs);
503
+ CGDataProviderRelease(dp);
504
+
505
+ double sx = (double)gWidth / srcW, sy = (double)gHeight / srcH;
506
+ // Keep video file playback letterboxed instead of cropping the source.
507
+ double s = MIN(sx, sy);
508
+ double dw = srcW * s, dh = srcH * s;
509
+ CGContextDrawImage(ctx, CGRectMake((gWidth - dw)/2.0, (gHeight - dh)/2.0, dw, dh), img);
510
+ CGImageRelease(img);
511
+ CGContextRelease(ctx);
512
+ CVPixelBufferUnlockBaseAddress(pb, kCVPixelBufferLock_ReadOnly);
513
+ return out;
514
+ }
515
+
516
+ static void RunVideoLoop(NSString *path) {
517
+ NSURL *url = [NSURL fileURLWithPath:path];
518
+ AVAsset *asset = [AVAsset assetWithURL:url];
519
+ NSArray<AVAssetTrack *> *tracks = [asset tracksWithMediaType:AVMediaTypeVideo];
520
+ if (tracks.count == 0) {
521
+ fprintf(stderr, "[serve-sim-camera] video → %s: no video tracks\n", path.UTF8String);
522
+ dispatch_semaphore_signal(gVideoStopped);
523
+ return;
524
+ }
525
+ AVAssetTrack *track = tracks.firstObject;
526
+
527
+ while (!atomic_load(&gVideoCancelled)) {
528
+ NSString *err = nil;
529
+ AVAssetReader *reader = nil;
530
+ AVAssetReaderTrackOutput *out = MakeVideoOutput(&reader, track, &err);
531
+ if (!out) {
532
+ fprintf(stderr, "[serve-sim-camera] video reader failed: %s\n", err.UTF8String ?: "?");
533
+ break;
534
+ }
535
+
536
+ uint64_t loopStartNs = MachAbsToNs(mach_absolute_time());
537
+ while (!atomic_load(&gVideoCancelled)) {
538
+ CMSampleBufferRef sb = [out copyNextSampleBuffer];
539
+ if (!sb) break; // end of track or read error → loop or exit
540
+ CMTime pts = CMSampleBufferGetPresentationTimeStamp(sb);
541
+ CVPixelBufferRef pb = CMSampleBufferGetImageBuffer(sb);
542
+ if (pb) {
543
+ uint8_t *frame = RenderPixelBufferToShmSize(pb);
544
+ if (frame) {
545
+ // Pace against wall clock: don't publish until the
546
+ // frame's PTS has caught up. Skips backwards (e.g.
547
+ // first frame of each loop) without sleeping.
548
+ if (CMTIME_IS_VALID(pts) && pts.timescale > 0) {
549
+ uint64_t targetNs = loopStartNs +
550
+ (uint64_t)((double)pts.value * 1e9 / pts.timescale);
551
+ uint64_t nowNs = MachAbsToNs(mach_absolute_time());
552
+ if (targetNs > nowNs) {
553
+ uint64_t sleepNs = targetNs - nowNs;
554
+ // Cap waits to 100ms slices so cancellation
555
+ // is responsive on long-PTS gaps.
556
+ while (sleepNs > 0 && !atomic_load(&gVideoCancelled)) {
557
+ uint64_t slice = sleepNs > 100000000ULL ? 100000000ULL : sleepNs;
558
+ struct timespec ts = {
559
+ .tv_sec = (time_t)(slice / 1000000000ULL),
560
+ .tv_nsec = (long)(slice % 1000000000ULL),
561
+ };
562
+ nanosleep(&ts, NULL);
563
+ sleepNs -= slice;
564
+ }
565
+ }
566
+ }
567
+ PublishFrame(frame);
568
+ free(frame);
569
+ }
570
+ }
571
+ CFRelease(sb);
572
+ }
573
+
574
+ AVAssetReaderStatus status = reader.status;
575
+ [reader cancelReading];
576
+ if (status == AVAssetReaderStatusFailed) {
577
+ fprintf(stderr, "[serve-sim-camera] video reader failed mid-loop: %s\n",
578
+ reader.error.localizedDescription.UTF8String ?: "?");
579
+ break;
580
+ }
581
+ // Otherwise rewind by re-creating the reader on the next iteration.
582
+ }
583
+ dispatch_semaphore_signal(gVideoStopped);
584
+ }
585
+
586
+ static BOOL StartVideoSource(NSString *path, NSString **err) {
587
+ if (!path.length) { if (err) *err = @"video source needs a path"; return NO; }
588
+ if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
589
+ if (err) *err = [NSString stringWithFormat:@"video file not found: %@", path];
590
+ return NO;
591
+ }
592
+ if (!gVideoQueue) {
593
+ gVideoQueue = dispatch_queue_create("serve-sim.cam.video", DISPATCH_QUEUE_SERIAL);
594
+ }
595
+ atomic_store(&gVideoCancelled, false);
596
+ gVideoStopped = dispatch_semaphore_create(0);
597
+ NSString *captured = [path copy];
598
+ dispatch_async(gVideoQueue, ^{ RunVideoLoop(captured); });
599
+ fprintf(stderr, "[serve-sim-camera] video → %s\n", path.UTF8String);
600
+ return YES;
601
+ }
602
+
603
+ static void StopVideoSource(void) {
604
+ if (!gVideoStopped) return;
605
+ atomic_store(&gVideoCancelled, true);
606
+ // Wait up to 1s for the decode loop to bail.
607
+ dispatch_semaphore_wait(gVideoStopped, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
608
+ gVideoStopped = nil;
609
+ }
610
+
611
+ #pragma mark Source switch entry point
612
+
613
+ static BOOL SwitchSource(SimCamSourceKind kind, NSString *arg, NSString **errOut) {
614
+ __block BOOL ok = NO;
615
+ __block NSString *err = nil;
616
+ dispatch_sync(gSourceQueue, ^{
617
+ switch (gActiveSource) {
618
+ case SimCamSourcePlaceholder: StopPlaceholderSource(); break;
619
+ case SimCamSourceWebcam: StopWebcamSource(); break;
620
+ case SimCamSourceImage: StopImageSource(); break;
621
+ case SimCamSourceVideo: StopVideoSource(); break;
622
+ default: break;
623
+ }
624
+ gActiveSource = SimCamSourceNone;
625
+ gActiveArg = nil;
626
+ switch (kind) {
627
+ case SimCamSourcePlaceholder: StartPlaceholderSource(); ok = YES; break;
628
+ case SimCamSourceWebcam: ok = StartWebcamSource(arg, &err); break;
629
+ case SimCamSourceImage: ok = StartImageSource(arg, &err); break;
630
+ case SimCamSourceVideo: ok = StartVideoSource(arg, &err); break;
631
+ default: ok = YES; break;
632
+ }
633
+ if (ok) { gActiveSource = kind; gActiveArg = [arg copy]; }
634
+ });
635
+ if (errOut) *errOut = err;
636
+ return ok;
637
+ }
638
+
639
+ static SimCamSourceKind ParseSourceName(NSString *name) {
640
+ if ([name isEqualToString:@"placeholder"]) return SimCamSourcePlaceholder;
641
+ if ([name isEqualToString:@"webcam"]) return SimCamSourceWebcam;
642
+ if ([name isEqualToString:@"image"]) return SimCamSourceImage;
643
+ if ([name isEqualToString:@"video"]) return SimCamSourceVideo;
644
+ if ([name isEqualToString:@"none"]) return SimCamSourceNone;
645
+ return -1;
646
+ }
647
+ static NSString *SourceName(SimCamSourceKind k) {
648
+ switch (k) {
649
+ case SimCamSourcePlaceholder: return @"placeholder";
650
+ case SimCamSourceWebcam: return @"webcam";
651
+ case SimCamSourceImage: return @"image";
652
+ case SimCamSourceVideo: return @"video";
653
+ default: return @"none";
654
+ }
655
+ }
656
+
657
+ #pragma mark - Control socket
658
+
659
+ static int gControlListenFd = -1;
660
+ static dispatch_source_t gAcceptSource;
661
+
662
+ static NSData *EncodeReply(NSDictionary *dict) {
663
+ NSMutableDictionary *m = dict.mutableCopy;
664
+ if (!m[@"source"]) m[@"source"] = SourceName(gActiveSource);
665
+ if (!m[@"arg"] && gActiveArg) m[@"arg"] = gActiveArg;
666
+ if (!m[@"mirror"] && gHeader) m[@"mirror"] = MirrorName(gHeader->mirrorMode);
667
+ NSError *e = nil;
668
+ NSData *json = [NSJSONSerialization dataWithJSONObject:m options:0 error:&e];
669
+ if (!json) json = [@"{\"ok\":false}" dataUsingEncoding:NSUTF8StringEncoding];
670
+ NSMutableData *out = json.mutableCopy;
671
+ [out appendBytes:"\n" length:1];
672
+ return out;
673
+ }
674
+
675
+ static void HandleControlLine(int fd, NSString *line) {
676
+ NSData *data = [line dataUsingEncoding:NSUTF8StringEncoding];
677
+ NSError *e = nil;
678
+ NSDictionary *cmd = [NSJSONSerialization JSONObjectWithData:data options:0 error:&e];
679
+ if (![cmd isKindOfClass:[NSDictionary class]]) {
680
+ NSData *r = EncodeReply(@{ @"ok": @NO, @"error": @"invalid json" });
681
+ write(fd, r.bytes, r.length);
682
+ return;
683
+ }
684
+ NSString *action = cmd[@"action"];
685
+ if ([action isEqualToString:@"status"]) {
686
+ NSData *r = EncodeReply(@{ @"ok": @YES });
687
+ write(fd, r.bytes, r.length);
688
+ return;
689
+ }
690
+ if ([action isEqualToString:@"shutdown"]) {
691
+ NSData *r = EncodeReply(@{ @"ok": @YES, @"shutdown": @YES });
692
+ write(fd, r.bytes, r.length);
693
+ gShouldExit = 1;
694
+ return;
695
+ }
696
+ if ([action isEqualToString:@"switch"]) {
697
+ SimCamSourceKind k = ParseSourceName(cmd[@"source"]);
698
+ if (k == (SimCamSourceKind)-1) {
699
+ NSData *r = EncodeReply(@{ @"ok": @NO, @"error": @"unknown source" });
700
+ write(fd, r.bytes, r.length);
701
+ return;
702
+ }
703
+ NSString *err = nil;
704
+ BOOL ok = SwitchSource(k, cmd[@"arg"], &err);
705
+ NSData *r = EncodeReply(ok
706
+ ? @{ @"ok": @YES }
707
+ : @{ @"ok": @NO, @"error": err ?: @"switch failed" });
708
+ write(fd, r.bytes, r.length);
709
+ return;
710
+ }
711
+ if ([action isEqualToString:@"setMirror"]) {
712
+ NSString *mode = cmd[@"mode"] ?: @"auto";
713
+ uint8_t code = ParseMirrorCode(mode);
714
+ if (code == 0xFE) {
715
+ NSData *r = EncodeReply(@{ @"ok": @NO, @"error": @"unknown mirror mode" });
716
+ write(fd, r.bytes, r.length);
717
+ return;
718
+ }
719
+ if (gHeader) gHeader->mirrorMode = code;
720
+ NSData *r = EncodeReply(@{ @"ok": @YES, @"mirror": MirrorName(code) });
721
+ write(fd, r.bytes, r.length);
722
+ return;
723
+ }
724
+ NSData *r = EncodeReply(@{ @"ok": @NO, @"error": @"unknown action" });
725
+ write(fd, r.bytes, r.length);
726
+ }
727
+
728
+ static void HandleClient(int fd) {
729
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
730
+ NSMutableData *buf = [NSMutableData new];
731
+ char tmp[1024];
732
+ while (1) {
733
+ ssize_t n = read(fd, tmp, sizeof(tmp));
734
+ if (n <= 0) break;
735
+ [buf appendBytes:tmp length:n];
736
+ while (1) {
737
+ NSString *all = [[NSString alloc] initWithData:buf encoding:NSUTF8StringEncoding];
738
+ NSRange nl = [all rangeOfString:@"\n"];
739
+ if (nl.location == NSNotFound) break;
740
+ NSString *line = [all substringToIndex:nl.location];
741
+ NSUInteger consumed = [[all substringToIndex:nl.location + 1]
742
+ lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
743
+ [buf replaceBytesInRange:NSMakeRange(0, consumed) withBytes:NULL length:0];
744
+ if (line.length > 0) HandleControlLine(fd, line);
745
+ }
746
+ }
747
+ close(fd);
748
+ });
749
+ }
750
+
751
+ static int OpenControlSocket(const char *path) {
752
+ unlink(path);
753
+ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
754
+ if (fd < 0) { perror("socket"); return -1; }
755
+ struct sockaddr_un addr = { .sun_family = AF_UNIX };
756
+ if (strlen(path) >= sizeof(addr.sun_path)) {
757
+ fprintf(stderr, "control socket path too long: %s\n", path);
758
+ close(fd); return -1;
759
+ }
760
+ strlcpy(addr.sun_path, path, sizeof(addr.sun_path));
761
+ if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
762
+ perror("bind"); close(fd); return -1;
763
+ }
764
+ if (listen(fd, 4) < 0) { perror("listen"); close(fd); return -1; }
765
+ chmod(path, 0600);
766
+ gControlListenFd = fd;
767
+ gAcceptSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ,
768
+ fd, 0, dispatch_get_global_queue(QOS_CLASS_UTILITY, 0));
769
+ dispatch_source_set_event_handler(gAcceptSource, ^{
770
+ int client = accept(fd, NULL, NULL);
771
+ if (client >= 0) HandleClient(client);
772
+ });
773
+ dispatch_resume(gAcceptSource);
774
+ return fd;
775
+ }
776
+
777
+ #pragma mark - Listing / shm setup / main
778
+
779
+ static void ListDevices(void) {
780
+ AVCaptureDeviceDiscoverySession *s = [AVCaptureDeviceDiscoverySession
781
+ discoverySessionWithDeviceTypes:@[
782
+ AVCaptureDeviceTypeBuiltInWideAngleCamera,
783
+ AVCaptureDeviceTypeExternal,
784
+ AVCaptureDeviceTypeContinuityCamera,
785
+ ]
786
+ mediaType:AVMediaTypeVideo
787
+ position:AVCaptureDevicePositionUnspecified];
788
+ for (AVCaptureDevice *d in s.devices) {
789
+ printf("%s\t%s\n", d.uniqueID.UTF8String, d.localizedName.UTF8String);
790
+ }
791
+ }
792
+
793
+ // Allocate the IOSurface ring and record their global IDs in the table.
794
+ static BOOL CreateSurfaces(void) {
795
+ #pragma clang diagnostic push
796
+ #pragma clang diagnostic ignored "-Wdeprecated-declarations"
797
+ NSDictionary *props = @{
798
+ (id)kIOSurfaceWidth: @(gWidth),
799
+ (id)kIOSurfaceHeight: @(gHeight),
800
+ (id)kIOSurfaceBytesPerElement: @4,
801
+ (id)kIOSurfacePixelFormat: @((uint32_t)kCVPixelFormatType_32BGRA),
802
+ // Global so the simulator process can resolve the surface by ID.
803
+ (id)kIOSurfaceIsGlobal: @YES,
804
+ };
805
+ #pragma clang diagnostic pop
806
+ for (uint32_t i = 0; i < SIMCAM_SURFACE_RING; i++) {
807
+ IOSurfaceRef s = IOSurfaceCreate((__bridge CFDictionaryRef)props);
808
+ if (!s) {
809
+ fprintf(stderr, "[serve-sim-camera] IOSurfaceCreate failed at %u\n", i);
810
+ return NO;
811
+ }
812
+ gSurfaces[i] = s;
813
+ gSurfaceTable->ids[i] = IOSurfaceGetID(s);
814
+ }
815
+ gSurfaceTable->surfaceCount = SIMCAM_SURFACE_RING;
816
+ gSurfaceTable->latestIndex = 0;
817
+ return YES;
818
+ }
819
+
820
+ static void ReleaseSurfaces(void) {
821
+ for (uint32_t i = 0; i < SIMCAM_SURFACE_RING; i++) {
822
+ if (gSurfaces[i]) { CFRelease(gSurfaces[i]); gSurfaces[i] = NULL; }
823
+ }
824
+ }
825
+
826
+ static int OpenShm(const char *name) {
827
+ size_t size = (size_t)SimCamControlSize();
828
+ shm_unlink(name);
829
+ int fd = shm_open(name, O_CREAT | O_RDWR, 0644);
830
+ if (fd < 0) { perror("shm_open"); return -1; }
831
+ if (ftruncate(fd, (off_t)size) < 0) { perror("ftruncate"); close(fd); return -1; }
832
+ void *map = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
833
+ if (map == MAP_FAILED) { perror("mmap"); close(fd); return -1; }
834
+ gHeader = (SimCamShmHeader *)map;
835
+ gSurfaceTable = (SimCamSurfaceTable *)((uint8_t *)map + sizeof(SimCamShmHeader));
836
+ memset(map, 0, size);
837
+ if (!CreateSurfaces()) { close(fd); return -1; }
838
+ gHeader->magic = SIMCAM_SHM_MAGIC;
839
+ gHeader->version = 2;
840
+ gHeader->width = gWidth;
841
+ gHeader->height = gHeight;
842
+ gHeader->pixelFormat = SIMCAM_PIXEL_BGRA;
843
+ gHeader->bytesPerRow = (uint32_t)IOSurfaceGetBytesPerRow(gSurfaces[0]);
844
+ gHeader->pixelByteSize = (uint64_t)gWidth * gHeight * 4;
845
+ gHeader->mirrorMode = SIMCAM_MIRROR_UNSET; // dylib falls back to env
846
+ return fd;
847
+ }
848
+
849
+ static uint8_t ParseMirrorCode(NSString *mode) {
850
+ if ([mode isEqualToString:@"on"]) return SIMCAM_MIRROR_ON;
851
+ if ([mode isEqualToString:@"off"]) return SIMCAM_MIRROR_OFF;
852
+ if ([mode isEqualToString:@"auto"]) return SIMCAM_MIRROR_AUTO;
853
+ if ([mode isEqualToString:@"unset"]) return SIMCAM_MIRROR_UNSET;
854
+ return 0xFE; // sentinel for "invalid"
855
+ }
856
+ static NSString *MirrorName(uint8_t code) {
857
+ switch (code) {
858
+ case SIMCAM_MIRROR_ON: return @"on";
859
+ case SIMCAM_MIRROR_OFF: return @"off";
860
+ case SIMCAM_MIRROR_AUTO: return @"auto";
861
+ case SIMCAM_MIRROR_UNSET: return @"unset";
862
+ default: return @"?";
863
+ }
864
+ }
865
+
866
+ int main(int argc, const char *argv[]) {
867
+ @autoreleasepool {
868
+ NSString *initialSource = @"placeholder";
869
+ NSString *initialArg = nil;
870
+ const char *socketPath = NULL;
871
+ BOOL list = NO;
872
+ for (int i = 1; i < argc; i++) {
873
+ const char *a = argv[i];
874
+ if (!strcmp(a, "--shm") && i+1 < argc) gShmName = argv[++i];
875
+ else if (!strcmp(a, "--socket") && i+1 < argc) socketPath = argv[++i];
876
+ else if (!strcmp(a, "--source") && i+1 < argc) initialSource = @(argv[++i]);
877
+ else if (!strcmp(a, "--arg") && i+1 < argc) initialArg = @(argv[++i]);
878
+ else if (!strcmp(a, "--device") && i+1 < argc) initialArg = @(argv[++i]); // back-compat
879
+ else if (!strcmp(a, "--width") && i+1 < argc) gWidth = (uint32_t)atoi(argv[++i]);
880
+ else if (!strcmp(a, "--height") && i+1 < argc) gHeight = (uint32_t)atoi(argv[++i]);
881
+ else if (!strcmp(a, "--list")) list = YES;
882
+ else if (!strcmp(a, "--help") || !strcmp(a, "-h")) {
883
+ printf("Usage: %s --shm <name> [--socket <path>] [--source placeholder|webcam|image] [--arg <value>] [--width N --height N]\n"
884
+ " %s --list\n", argv[0], argv[0]);
885
+ return 0;
886
+ }
887
+ }
888
+ if (list) { ListDevices(); return 0; }
889
+ if (!gShmName) { fprintf(stderr, "error: --shm <name> required\n"); return 64; }
890
+
891
+ // Webcam back-compat: if user passed --device but no --source we
892
+ // default to webcam mode rather than placeholder.
893
+ if (initialArg && [initialSource isEqualToString:@"placeholder"]
894
+ && [@[@"--device"] containsObject:@"--device"]) {
895
+ // (no-op marker; --device implies webcam below if user intended it)
896
+ }
897
+
898
+ if (OpenShm(gShmName) < 0) return 1;
899
+ fprintf(stderr, "[serve-sim-camera] shm \"%s\" + %u IOSurfaces (%ux%u BGRA)\n",
900
+ gShmName, SIMCAM_SURFACE_RING, gWidth, gHeight);
901
+
902
+ gSourceQueue = dispatch_queue_create("simcam.helper.source", DISPATCH_QUEUE_SERIAL);
903
+
904
+ SimCamSourceKind k = ParseSourceName(initialSource);
905
+ if (k == (SimCamSourceKind)-1) {
906
+ fprintf(stderr, "[serve-sim-camera] unknown --source %s, defaulting to placeholder\n",
907
+ initialSource.UTF8String);
908
+ k = SimCamSourcePlaceholder;
909
+ }
910
+ NSString *err = nil;
911
+ if (!SwitchSource(k, initialArg, &err)) {
912
+ fprintf(stderr, "[serve-sim-camera] initial source failed: %s — falling back to placeholder\n",
913
+ err.UTF8String ?: "?");
914
+ (void)SwitchSource(SimCamSourcePlaceholder, nil, NULL);
915
+ }
916
+
917
+ if (socketPath) {
918
+ if (OpenControlSocket(socketPath) < 0) {
919
+ fprintf(stderr, "[serve-sim-camera] control socket open failed: %s\n", socketPath);
920
+ } else {
921
+ fprintf(stderr, "[serve-sim-camera] control socket %s\n", socketPath);
922
+ }
923
+ }
924
+
925
+ signal(SIGINT, HandleSig);
926
+ signal(SIGTERM, HandleSig);
927
+
928
+ fprintf(stderr, "[serve-sim-camera] running — Ctrl+C to stop\n");
929
+ while (!gShouldExit) {
930
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]];
931
+ }
932
+ if (gAcceptSource) dispatch_source_cancel(gAcceptSource);
933
+ if (gControlListenFd >= 0) { close(gControlListenFd); if (socketPath) unlink(socketPath); }
934
+ StopPlaceholderSource();
935
+ StopWebcamSource();
936
+ StopVideoSource();
937
+ ReleaseSurfaces();
938
+ if (gShmName) shm_unlink(gShmName);
939
+ fprintf(stderr, "[serve-sim-camera] stopped\n");
940
+ return 0;
941
+ }
942
+ }