Skip to content

Commit 44d90b3

Browse files
authored
Add experimental WebRTC simulator streaming JPEG (#10)
1 parent f1dea64 commit 44d90b3

17 files changed

Lines changed: 1107 additions & 69 deletions

File tree

README.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ view inside the editor.
1919

2020
## Features
2121

22-
- WebTransport streaming server in Rust, plus experimental WebRTC for runner previews, using hardware encoded HEVC/H.264 video
22+
- WebTransport streaming server in Rust, plus experimental WebRTC for runner previews, using HEVC/H.264 video or full-resolution JPEG on CI runners
2323
- Simulator control & inspection using private accessibility APIs
2424
- CoreSimulator chrome asset rendering for device bezels
2525
- NativeScript and React Native runtime inspector plugins, plus a native UIKit inspector framework for other apps
@@ -56,6 +56,62 @@ simdeck tap <udid> 0.5 0.5 --normalized
5656
simdeck describe <udid> --format agent --max-depth 2
5757
```
5858

59+
## Daemon
60+
61+
Manage the project daemon explicitly when needed:
62+
63+
```sh
64+
simdeck daemon start
65+
simdeck daemon status
66+
simdeck daemon stop
67+
```
68+
69+
`simdeck daemon` manages the normal per-project warm process. For an always-on
70+
daemon that is available after login, use the macOS user service commands:
71+
72+
```sh
73+
simdeck service on
74+
simdeck service off
75+
```
76+
77+
This uses a LaunchAgent, keeps the server bound to localhost by default, and is
78+
best for agents or editor integrations that should be able to open SimDeck
79+
without first starting a project daemon.
80+
81+
Use software H.264 when macOS screen recording starves the hardware encoder:
82+
83+
```sh
84+
simdeck daemon start --video-codec h264-software
85+
```
86+
87+
On GitHub Actions macOS runners where VideoToolbox hardware encode is not
88+
available, use the experimental full-resolution JPEG data-channel stream:
89+
90+
```sh
91+
simdeck daemon start --video-codec jpeg
92+
# open http://127.0.0.1:4310?transport=webrtc-data
93+
```
94+
95+
For LAN browser access:
96+
97+
```sh
98+
simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open
99+
```
100+
101+
Restart the CoreSimulator service layer when `simctl` reports a stale service
102+
version or the live display gets stuck before the first frame:
103+
104+
```sh
105+
simdeck core-simulator restart
106+
```
107+
108+
You can also start or stop the CoreSimulator service layer explicitly:
109+
110+
```sh
111+
simdeck core-simulator start
112+
simdeck core-simulator shutdown
113+
```
114+
59115
## CLI
60116

61117
```sh

cli/XCWH264Encoder.m

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#import "XCWH264Encoder.h"
22

33
#import <CoreMedia/CoreMedia.h>
4+
#import <ImageIO/ImageIO.h>
45
#import <os/lock.h>
56
#import <QuartzCore/QuartzCore.h>
67
#import <VideoToolbox/VideoToolbox.h>
@@ -15,6 +16,7 @@ typedef NS_ENUM(NSUInteger, XCWVideoEncoderMode) {
1516
XCWVideoEncoderModeHEVCHardware,
1617
XCWVideoEncoderModeH264Hardware,
1718
XCWVideoEncoderModeH264Software,
19+
XCWVideoEncoderModeJPEG,
1820
};
1921

2022
static XCWVideoEncoderMode XCWVideoEncoderModeFromEnvironment(void) {
@@ -25,6 +27,9 @@ static XCWVideoEncoderMode XCWVideoEncoderModeFromEnvironment(void) {
2527
if ([value isEqualToString:@"h264-software"] || [value isEqualToString:@"software-h264"]) {
2628
return XCWVideoEncoderModeH264Software;
2729
}
30+
if ([value isEqualToString:@"jpeg"] || [value isEqualToString:@"jpg"] || [value isEqualToString:@"mjpeg"]) {
31+
return XCWVideoEncoderModeJPEG;
32+
}
2833
return XCWVideoEncoderModeHEVCHardware;
2934
}
3035

@@ -33,6 +38,8 @@ static CMVideoCodecType XCWVideoCodecTypeForMode(XCWVideoEncoderMode mode) {
3338
case XCWVideoEncoderModeH264Hardware:
3439
case XCWVideoEncoderModeH264Software:
3540
return kCMVideoCodecType_H264;
41+
case XCWVideoEncoderModeJPEG:
42+
return kCMVideoCodecType_JPEG;
3643
case XCWVideoEncoderModeHEVCHardware:
3744
default:
3845
return kCMVideoCodecType_HEVC;
@@ -45,6 +52,8 @@ static CMVideoCodecType XCWVideoCodecTypeForMode(XCWVideoEncoderMode mode) {
4552
return @"h264";
4653
case XCWVideoEncoderModeH264Software:
4754
return @"h264-software";
55+
case XCWVideoEncoderModeJPEG:
56+
return @"jpeg";
4857
case XCWVideoEncoderModeHEVCHardware:
4958
default:
5059
return @"hevc";
@@ -57,6 +66,8 @@ static CMVideoCodecType XCWVideoCodecTypeForMode(XCWVideoEncoderMode mode) {
5766
return nil;
5867
case XCWVideoEncoderModeH264Software:
5968
return @"com.apple.videotoolbox.videoencoder.h264";
69+
case XCWVideoEncoderModeJPEG:
70+
return nil;
6071
case XCWVideoEncoderModeHEVCHardware:
6172
default:
6273
return nil;
@@ -173,11 +184,106 @@ static uint32_t XCWReverseBits32(uint32_t value) {
173184
return @"hevc";
174185
case kCMVideoCodecType_H264:
175186
return @"h264";
187+
case kCMVideoCodecType_JPEG:
188+
return @"jpeg";
176189
default:
177190
return [NSString stringWithFormat:@"0x%08x", (unsigned int)codecType];
178191
}
179192
}
180193

194+
static CGFloat XCWJPEGQualityFromEnvironment(void) {
195+
NSString *value = [[NSProcessInfo processInfo] environment][@"SIMDECK_JPEG_QUALITY"];
196+
double quality = value.length > 0 ? value.doubleValue : 1.0;
197+
if (!isfinite(quality) || quality < 0.1 || quality > 1.0) {
198+
return 1.0;
199+
}
200+
return (CGFloat)quality;
201+
}
202+
203+
static CGColorSpaceRef XCWDeviceRGBColorSpace(void) {
204+
static CGColorSpaceRef colorSpace = NULL;
205+
static dispatch_once_t onceToken;
206+
dispatch_once(&onceToken, ^{
207+
colorSpace = CGColorSpaceCreateDeviceRGB();
208+
});
209+
return colorSpace;
210+
}
211+
212+
static NSData *XCWJPEGDataFromPixelBuffer(CVPixelBufferRef pixelBuffer) {
213+
if (pixelBuffer == NULL) {
214+
return nil;
215+
}
216+
217+
CGImageRef image = NULL;
218+
BOOL didLockPixelBuffer = NO;
219+
OSType pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer);
220+
if (pixelFormat == kCVPixelFormatType_32BGRA &&
221+
CVPixelBufferLockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly) == kCVReturnSuccess) {
222+
didLockPixelBuffer = YES;
223+
void *baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer);
224+
size_t width = CVPixelBufferGetWidth(pixelBuffer);
225+
size_t height = CVPixelBufferGetHeight(pixelBuffer);
226+
size_t bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer);
227+
if (baseAddress != NULL && width > 0 && height > 0 && bytesPerRow >= width * 4) {
228+
CGDataProviderRef provider = CGDataProviderCreateWithData(NULL,
229+
baseAddress,
230+
bytesPerRow * height,
231+
NULL);
232+
if (provider != NULL) {
233+
image = CGImageCreate(width,
234+
height,
235+
8,
236+
32,
237+
bytesPerRow,
238+
XCWDeviceRGBColorSpace(),
239+
kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst,
240+
provider,
241+
NULL,
242+
false,
243+
kCGRenderingIntentDefault);
244+
CGDataProviderRelease(provider);
245+
}
246+
}
247+
}
248+
249+
if (image == NULL) {
250+
if (didLockPixelBuffer) {
251+
CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
252+
didLockPixelBuffer = NO;
253+
}
254+
OSStatus imageStatus = VTCreateCGImageFromCVPixelBuffer(pixelBuffer, NULL, &image);
255+
if (imageStatus != noErr || image == NULL) {
256+
return nil;
257+
}
258+
}
259+
260+
NSMutableData *data = [NSMutableData data];
261+
CGImageDestinationRef destination =
262+
CGImageDestinationCreateWithData((__bridge CFMutableDataRef)data,
263+
CFSTR("public.jpeg"),
264+
1,
265+
NULL);
266+
if (destination == NULL) {
267+
CGImageRelease(image);
268+
if (didLockPixelBuffer) {
269+
CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
270+
}
271+
return nil;
272+
}
273+
274+
NSDictionary *properties = @{
275+
(__bridge NSString *)kCGImageDestinationLossyCompressionQuality: @(XCWJPEGQualityFromEnvironment()),
276+
};
277+
CGImageDestinationAddImage(destination, image, (__bridge CFDictionaryRef)properties);
278+
BOOL ok = CGImageDestinationFinalize(destination);
279+
CFRelease(destination);
280+
CGImageRelease(image);
281+
if (didLockPixelBuffer) {
282+
CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
283+
}
284+
return ok && data.length > 0 ? data : nil;
285+
}
286+
181287
static NSData *XCWCopySampleData(CMSampleBufferRef sampleBuffer) {
182288
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
183289
if (blockBuffer == NULL) {
@@ -505,6 +611,12 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer {
505611
return NO;
506612
}
507613

614+
if (_encoderMode == XCWVideoEncoderModeJPEG) {
615+
return [self encodeJPEGPixelBufferLocked:pixelBuffer
616+
sourceWidth:sourceWidth
617+
sourceHeight:sourceHeight];
618+
}
619+
508620
if (![self ensureCompressionSessionWithWidth:targetWidth height:targetHeight]) {
509621
return NO;
510622
}
@@ -553,6 +665,40 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer {
553665
return YES;
554666
}
555667

668+
- (BOOL)encodeJPEGPixelBufferLocked:(CVPixelBufferRef)pixelBuffer
669+
sourceWidth:(int32_t)sourceWidth
670+
sourceHeight:(int32_t)sourceHeight {
671+
uint64_t submittedAtUs = (uint64_t)(CACurrentMediaTime() * 1000000.0);
672+
if (_timestampOriginUs == 0) {
673+
_timestampOriginUs = submittedAtUs;
674+
}
675+
uint64_t relativeTimestampUs = submittedAtUs - _timestampOriginUs;
676+
677+
NSData *jpegData = XCWJPEGDataFromPixelBuffer(pixelBuffer);
678+
if (jpegData.length == 0) {
679+
_encodeFailureCount += 1;
680+
_lastEncodeStatus = -1;
681+
return NO;
682+
}
683+
684+
_width = sourceWidth;
685+
_height = sourceHeight;
686+
_submittedFrameCount += 1;
687+
_outputFrameCount += 1;
688+
_keyFrameOutputCount += 1;
689+
_lastEncodeStatus = noErr;
690+
uint64_t nowUs = (uint64_t)(CACurrentMediaTime() * 1000000.0);
691+
_latestEncodeLatencyUs = nowUs >= submittedAtUs ? nowUs - submittedAtUs : 0;
692+
693+
self.outputHandler(jpegData,
694+
relativeTimestampUs,
695+
YES,
696+
@"jpeg",
697+
nil,
698+
CGSizeMake(sourceWidth, sourceHeight));
699+
return YES;
700+
}
701+
556702
- (BOOL)ensureCompressionSessionWithWidth:(int32_t)width height:(int32_t)height {
557703
if (_compressionSession != NULL && _width == width && _height == height) {
558704
return YES;

0 commit comments

Comments
 (0)