serve-sim 0.1.23 → 0.1.24

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