@appium/coresim 1.7.0 → 1.9.0
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.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/binding.gyp +3 -1
- package/lib/src/commands/jpeg-stream.d.ts +44 -0
- package/lib/src/commands/jpeg-stream.d.ts.map +1 -0
- package/lib/src/commands/jpeg-stream.js +198 -0
- package/lib/src/commands/jpeg-stream.js.map +1 -0
- package/lib/src/commands/ui.d.ts +21 -0
- package/lib/src/commands/ui.d.ts.map +1 -1
- package/lib/src/commands/ui.js +22 -0
- package/lib/src/commands/ui.js.map +1 -1
- package/lib/src/index.d.ts +2 -1
- package/lib/src/index.d.ts.map +1 -1
- package/lib/src/index.js +2 -1
- package/lib/src/index.js.map +1 -1
- package/lib/src/native-simctl.d.ts +2 -0
- package/lib/src/native-simctl.d.ts.map +1 -1
- package/lib/src/native-simctl.js +6 -1
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/types.d.ts +64 -2
- package/lib/src/types.d.ts.map +1 -1
- package/lib/src/types.js +8 -0
- package/lib/src/types.js.map +1 -1
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
- package/src/commands/jpeg-stream.ts +233 -0
- package/src/commands/ui.ts +27 -0
- package/src/coresim.mm +199 -6
- package/src/index.ts +4 -0
- package/src/native/audio_encoder.mm +1 -1
- package/src/native/av_recording.h +1 -1
- package/src/native/av_recording.mm +76 -27
- package/src/native/sim_audio_tap.mm +3 -3
- package/src/native/sim_device.h +11 -0
- package/src/native/sim_device.mm +53 -0
- package/src/native/sim_jpeg_stream.h +69 -0
- package/src/native/sim_jpeg_stream.mm +322 -0
- package/src/native/sim_orientation.h +16 -0
- package/src/native/sim_orientation.mm +58 -0
- package/src/native/sim_pasteboard.mm +3 -3
- package/src/native/sim_process.mm +1 -1
- package/src/native/sim_screenshot.h +13 -0
- package/src/native/sim_screenshot.mm +50 -25
- package/src/native/sim_video_recording.mm +2 -43
- package/src/native/tcc_privacy.mm +1 -1
- package/src/native/video_encoder.h +9 -2
- package/src/native/video_encoder.mm +241 -21
- package/src/native-simctl.ts +7 -0
- package/src/types.ts +69 -2
|
@@ -12,7 +12,7 @@ namespace coresim {
|
|
|
12
12
|
|
|
13
13
|
namespace {
|
|
14
14
|
|
|
15
|
-
NSString* const kScreenshotErrorDomain = @"
|
|
15
|
+
NSString* const kScreenshotErrorDomain = @"io.appium.coresim.Screenshot";
|
|
16
16
|
|
|
17
17
|
NSError* MakeError(NSInteger code, NSString* message) {
|
|
18
18
|
return [NSError errorWithDomain:kScreenshotErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
@@ -152,6 +152,27 @@ id ResolveCaptureDisplay(id device, NSString* displayId, NSError** error) {
|
|
|
152
152
|
return ResolveDisplayDescriptor(candidates, displayId, error);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// The real protocol is SimScreenCaptureService (see CLAUDE.md) — found by scanning ioPorts for
|
|
156
|
+
// whichever descriptor responds to startRecordingFromScreen:..., since no header exists for it.
|
|
157
|
+
id ResolveScreenCaptureService(id device, NSError** error) {
|
|
158
|
+
static const std::string kStartRecordingSelector =
|
|
159
|
+
"startRecordingFromScreen:maskPolicy:assetWriterOutputSettings:outputFile:completionQueue:completionHandler:";
|
|
160
|
+
id ioClient = IdGetter(device, "io");
|
|
161
|
+
if (ioClient == nil) {
|
|
162
|
+
*error = MakeError(11, @"Device has no IO client available — is it booted?");
|
|
163
|
+
return nil;
|
|
164
|
+
}
|
|
165
|
+
NSArray* ports = IdGetter(ioClient, "ioPorts");
|
|
166
|
+
SEL selector = NSSelectorFromString(@(kStartRecordingSelector.c_str()));
|
|
167
|
+
for (id port in ports) {
|
|
168
|
+
id descriptor = IdGetter(port, "descriptor");
|
|
169
|
+
if (descriptor != nil && [descriptor respondsToSelector:selector]) {
|
|
170
|
+
return descriptor;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw NativeSimUnavailableError("selector", kStartRecordingSelector, CoreSimulatorFrameworkVersion());
|
|
174
|
+
}
|
|
175
|
+
|
|
155
176
|
id CurrentDisplaySurface(id descriptor) { return RenderableSurface(descriptor); }
|
|
156
177
|
|
|
157
178
|
NSArray<NSDictionary*>* ListDisplays(id device, NSError** error) {
|
|
@@ -171,6 +192,32 @@ NSArray<NSDictionary*>* ListDisplays(id device, NSError** error) {
|
|
|
171
192
|
return result;
|
|
172
193
|
}
|
|
173
194
|
|
|
195
|
+
NSData* EncodeImage(CGImageRef cgImage, ScreenshotFormat format, NSNumber* jpegQualityPercent, NSError** error) {
|
|
196
|
+
NSString* uti = format == ScreenshotFormat::kJPEG ? kJPEGUTI : kPNGUTI;
|
|
197
|
+
NSMutableData* imageData = [NSMutableData data];
|
|
198
|
+
CGImageDestinationRef destination =
|
|
199
|
+
CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, (__bridge CFStringRef)uti, 1, NULL);
|
|
200
|
+
if (destination == nullptr) {
|
|
201
|
+
*error = MakeError(7, @"Failed to create an image encoder");
|
|
202
|
+
return nil;
|
|
203
|
+
}
|
|
204
|
+
// kCGImageDestinationLossyCompressionQuality is meaningless for PNG (always lossless) — ImageIO
|
|
205
|
+
// silently ignores properties a format doesn't use, so this is only gated on jpegQualityPercent
|
|
206
|
+
// being present, not on `format` too.
|
|
207
|
+
NSDictionary* properties =
|
|
208
|
+
jpegQualityPercent != nil
|
|
209
|
+
? @{(NSString*)kCGImageDestinationLossyCompressionQuality : @(jpegQualityPercent.doubleValue / 100.0)}
|
|
210
|
+
: nil;
|
|
211
|
+
CGImageDestinationAddImage(destination, cgImage, (__bridge CFDictionaryRef)properties);
|
|
212
|
+
BOOL ok = CGImageDestinationFinalize(destination);
|
|
213
|
+
CFRelease(destination);
|
|
214
|
+
if (!ok) {
|
|
215
|
+
*error = MakeError(8, @"Failed to encode the image");
|
|
216
|
+
return nil;
|
|
217
|
+
}
|
|
218
|
+
return imageData;
|
|
219
|
+
}
|
|
220
|
+
|
|
174
221
|
NSData* CaptureScreenshot(id device, NSString* displayId, ScreenshotFormat format, NSNumber* jpegQualityPercent,
|
|
175
222
|
NSError** error) {
|
|
176
223
|
id descriptor = ResolveCaptureDisplay(device, displayId, error);
|
|
@@ -193,7 +240,7 @@ NSData* CaptureScreenshot(id device, NSString* displayId, ScreenshotFormat forma
|
|
|
193
240
|
|
|
194
241
|
// A fresh CIContext per call, matching this operation's one-shot semantics (mirrors
|
|
195
242
|
// simctl's own screenshot command) rather than the persistent, reused context a
|
|
196
|
-
// continuous video/streaming path would want.
|
|
243
|
+
// continuous video/streaming path would want (JpegStreamSession — see sim_jpeg_stream.mm).
|
|
197
244
|
CIContext* context = [CIContext contextWithOptions:nil];
|
|
198
245
|
CGImageRef cgImage = [context createCGImage:ciImage fromRect:ciImage.extent];
|
|
199
246
|
if (cgImage == nil) {
|
|
@@ -201,30 +248,8 @@ NSData* CaptureScreenshot(id device, NSString* displayId, ScreenshotFormat forma
|
|
|
201
248
|
return nil;
|
|
202
249
|
}
|
|
203
250
|
|
|
204
|
-
|
|
205
|
-
NSMutableData* imageData = [NSMutableData data];
|
|
206
|
-
CGImageDestinationRef destination =
|
|
207
|
-
CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, (__bridge CFStringRef)uti, 1, NULL);
|
|
208
|
-
if (destination == nullptr) {
|
|
209
|
-
CGImageRelease(cgImage);
|
|
210
|
-
*error = MakeError(7, @"Failed to create an image encoder for the captured screenshot");
|
|
211
|
-
return nil;
|
|
212
|
-
}
|
|
213
|
-
// kCGImageDestinationLossyCompressionQuality is meaningless for PNG (always lossless) — ImageIO
|
|
214
|
-
// silently ignores properties a format doesn't use, so this is only gated on jpegQualityPercent
|
|
215
|
-
// being present, not on `format` too.
|
|
216
|
-
NSDictionary* properties =
|
|
217
|
-
jpegQualityPercent != nil
|
|
218
|
-
? @{(NSString*)kCGImageDestinationLossyCompressionQuality : @(jpegQualityPercent.doubleValue / 100.0)}
|
|
219
|
-
: nil;
|
|
220
|
-
CGImageDestinationAddImage(destination, cgImage, (__bridge CFDictionaryRef)properties);
|
|
221
|
-
BOOL ok = CGImageDestinationFinalize(destination);
|
|
222
|
-
CFRelease(destination);
|
|
251
|
+
NSData* imageData = EncodeImage(cgImage, format, jpegQualityPercent, error);
|
|
223
252
|
CGImageRelease(cgImage);
|
|
224
|
-
if (!ok) {
|
|
225
|
-
*error = MakeError(8, @"Failed to encode the captured screenshot");
|
|
226
|
-
return nil;
|
|
227
|
-
}
|
|
228
253
|
return imageData;
|
|
229
254
|
}
|
|
230
255
|
|
|
@@ -8,50 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
namespace coresim {
|
|
10
10
|
|
|
11
|
-
namespace {
|
|
12
|
-
|
|
13
|
-
NSString* const kVideoRecordingErrorDomain = @"com.appium.coresim.VideoRecording";
|
|
14
|
-
|
|
15
|
-
NSError* MakeError(NSInteger code, NSString* message) {
|
|
16
|
-
return [NSError errorWithDomain:kVideoRecordingErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
id IdGetter(id target, const std::string& selectorName) {
|
|
20
|
-
RequireSelector(target, selectorName);
|
|
21
|
-
SEL selector = SelectorNamed(selectorName);
|
|
22
|
-
return SafeInvoke([&] {
|
|
23
|
-
using Fn = id (*)(id, SEL);
|
|
24
|
-
return ((Fn)objc_msgSend)(target, selector);
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// The device-wide "capture service" port (real protocol: SimScreenCaptureService — see CLAUDE.md),
|
|
29
|
-
// distinct from the display descriptor passed as `screen` below. Found by scanning ioPorts since
|
|
30
|
-
// no header exists. Throws NativeSimUnavailableError (not NSError**) if absent.
|
|
31
|
-
id ResolveVideoCaptureService(id device, NSError** error) {
|
|
32
|
-
static const std::string kStartRecordingSelector =
|
|
33
|
-
"startRecordingFromScreen:maskPolicy:assetWriterOutputSettings:outputFile:completionQueue:completionHandler:";
|
|
34
|
-
id ioClient = IdGetter(device, "io");
|
|
35
|
-
if (ioClient == nil) {
|
|
36
|
-
*error = MakeError(1, @"Device has no IO client available — is it booted?");
|
|
37
|
-
return nil;
|
|
38
|
-
}
|
|
39
|
-
NSArray* ports = IdGetter(ioClient, "ioPorts");
|
|
40
|
-
SEL selector = NSSelectorFromString(@(kStartRecordingSelector.c_str()));
|
|
41
|
-
for (id port in ports) {
|
|
42
|
-
id descriptor = IdGetter(port, "descriptor");
|
|
43
|
-
if (descriptor != nil && [descriptor respondsToSelector:selector]) {
|
|
44
|
-
return descriptor;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
throw NativeSimUnavailableError("selector", kStartRecordingSelector, CoreSimulatorFrameworkVersion());
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
} // namespace
|
|
51
|
-
|
|
52
11
|
BOOL StartVideoRecording(id device, NSString* displayId, VideoMaskPolicy mask, NSDictionary* assetWriterOutputSettings,
|
|
53
12
|
NSString* outputFile, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error) {
|
|
54
|
-
id captureService =
|
|
13
|
+
id captureService = ResolveScreenCaptureService(device, error);
|
|
55
14
|
if (captureService == nil) {
|
|
56
15
|
return NO;
|
|
57
16
|
}
|
|
@@ -71,7 +30,7 @@ BOOL StartVideoRecording(id device, NSString* displayId, VideoMaskPolicy mask, N
|
|
|
71
30
|
}
|
|
72
31
|
|
|
73
32
|
BOOL StopVideoRecording(id device, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error) {
|
|
74
|
-
id captureService =
|
|
33
|
+
id captureService = ResolveScreenCaptureService(device, error);
|
|
75
34
|
if (captureService == nil) {
|
|
76
35
|
return NO;
|
|
77
36
|
}
|
|
@@ -9,7 +9,7 @@ namespace coresim {
|
|
|
9
9
|
|
|
10
10
|
namespace {
|
|
11
11
|
|
|
12
|
-
NSString* const kTCCErrorDomain = @"
|
|
12
|
+
NSString* const kTCCErrorDomain = @"io.appium.coresim.TCCPrivacy";
|
|
13
13
|
|
|
14
14
|
NSError* MakeError(int code, NSString* message) {
|
|
15
15
|
return [NSError errorWithDomain:kTCCErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
@@ -15,8 +15,15 @@ enum class VideoStreamCodec { kH264, kHEVC };
|
|
|
15
15
|
struct VideoEncoderOptions {
|
|
16
16
|
VideoStreamCodec codec = VideoStreamCodec::kH264;
|
|
17
17
|
NSString* displayId = nil;
|
|
18
|
-
double fps =
|
|
19
|
-
int bitrate =
|
|
18
|
+
double fps = 60.0;
|
|
19
|
+
int bitrate = 4000000;
|
|
20
|
+
|
|
21
|
+
// Freezes the encoded frame size at whatever it is on the first frame, forever — a rotation
|
|
22
|
+
// afterward letterboxes into that size instead of resizing the session to match. Internal-only
|
|
23
|
+
// (not JS-configurable): AVRecordingSession forces this on, since its AVAssetWriter track's
|
|
24
|
+
// dimensions are fixed for the file's life (see CLAUDE.md); a live stream has no such constraint
|
|
25
|
+
// and keeps the default (false), matching its own decoded dimensions to the actual rotation.
|
|
26
|
+
bool fixedFrameSize = false;
|
|
20
27
|
};
|
|
21
28
|
|
|
22
29
|
// Polls the live display IOSurface (sim_screenshot.h) on a serial queue and encodes changed
|
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
#include "video_encoder.h"
|
|
2
2
|
|
|
3
|
+
#import <CoreImage/CoreImage.h>
|
|
3
4
|
#import <CoreVideo/CoreVideo.h>
|
|
4
5
|
#import <IOSurface/IOSurface.h>
|
|
5
6
|
#import <VideoToolbox/VideoToolbox.h>
|
|
6
7
|
|
|
7
8
|
#include <algorithm>
|
|
8
9
|
#include <atomic>
|
|
10
|
+
#include <cmath>
|
|
11
|
+
#include <memory>
|
|
9
12
|
|
|
10
13
|
#include "monotonic_clock.h"
|
|
11
14
|
#include "nserror_bridge.h"
|
|
12
15
|
#include "safe_dispatch.h"
|
|
16
|
+
#include "sim_orientation.h"
|
|
13
17
|
#include "sim_screenshot.h"
|
|
14
18
|
|
|
15
19
|
namespace coresim {
|
|
16
20
|
|
|
17
21
|
namespace {
|
|
18
22
|
|
|
19
|
-
NSString* const kVideoEncoderErrorDomain = @"
|
|
23
|
+
NSString* const kVideoEncoderErrorDomain = @"io.appium.coresim.VideoEncoder";
|
|
20
24
|
|
|
21
25
|
NSError* MakeError(NSInteger code, NSString* message) {
|
|
22
26
|
return [NSError errorWithDomain:kVideoEncoderErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
@@ -81,6 +85,79 @@ void AppendParameterSets(std::vector<uint8_t>& out, CMFormatDescriptionRef forma
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
// Clockwise degrees to visually rotate a captured frame to correct for `orientation` — verified
|
|
89
|
+
// empirically against a live device, not assumed from the enum names (see CLAUDE.md).
|
|
90
|
+
int RotationDegreesForOrientation(int32_t orientation) {
|
|
91
|
+
switch (orientation) {
|
|
92
|
+
case 2:
|
|
93
|
+
return 180;
|
|
94
|
+
case 3:
|
|
95
|
+
return 270;
|
|
96
|
+
case 4:
|
|
97
|
+
return 90;
|
|
98
|
+
default:
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A rotated copy of `surface`'s frame, via CoreImage — the raw surface itself never reflects a live
|
|
104
|
+
// rotation (see CLAUDE.md). Caller owns the result (CVPixelBufferRelease). Returns nullptr on any
|
|
105
|
+
// (transient) failure.
|
|
106
|
+
//
|
|
107
|
+
// `canvasWidth`/`canvasHeight` (both 0, or both positive): 0 sizes the output to the rotated
|
|
108
|
+
// content itself (swapped at 90/270 — the default, used for streaming). A positive size instead
|
|
109
|
+
// freezes the output at that size regardless of rotation, letterboxing the (possibly
|
|
110
|
+
// differently-shaped) rotated content into it — required for AVRecordingSession, whose
|
|
111
|
+
// AVAssetWriter track's dimensions are fixed for the file's life once the first frame is appended;
|
|
112
|
+
// resizing later frames instead of letterboxing them corrupts playback (squashed video — see
|
|
113
|
+
// CLAUDE.md).
|
|
114
|
+
CVPixelBufferRef RotatedPixelBuffer(IOSurfaceRef surface, int degrees, CIContext* context, int32_t canvasWidth,
|
|
115
|
+
int32_t canvasHeight) {
|
|
116
|
+
CIImage* image = [CIImage imageWithIOSurface:surface];
|
|
117
|
+
if (image == nil) {
|
|
118
|
+
return nullptr;
|
|
119
|
+
}
|
|
120
|
+
// CoreImage is Y-up, unlike a raster's Y-down row order, so a visual CW rotation needs a
|
|
121
|
+
// negative angle here (verified against ground truth, not just derived — see CLAUDE.md). The
|
|
122
|
+
// translation afterward re-zeroes the extent's origin so CIContext renders it at (0, 0).
|
|
123
|
+
CGAffineTransform rotate = CGAffineTransformMakeRotation(-degrees * M_PI / 180.0);
|
|
124
|
+
CIImage* rotated = [image imageByApplyingTransform:rotate];
|
|
125
|
+
CIImage* normalized = [rotated
|
|
126
|
+
imageByApplyingTransform:CGAffineTransformMakeTranslation(-rotated.extent.origin.x, -rotated.extent.origin.y)];
|
|
127
|
+
|
|
128
|
+
BOOL letterbox = canvasWidth > 0 && canvasHeight > 0;
|
|
129
|
+
size_t width =
|
|
130
|
+
letterbox ? static_cast<size_t>(canvasWidth) : static_cast<size_t>(std::lround(normalized.extent.size.width));
|
|
131
|
+
size_t height =
|
|
132
|
+
letterbox ? static_cast<size_t>(canvasHeight) : static_cast<size_t>(std::lround(normalized.extent.size.height));
|
|
133
|
+
if (width == 0 || height == 0) {
|
|
134
|
+
return nullptr;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
CIImage* fitted = normalized;
|
|
138
|
+
CGRect bounds = normalized.extent;
|
|
139
|
+
if (letterbox) {
|
|
140
|
+
double scale = std::min(width / normalized.extent.size.width, height / normalized.extent.size.height);
|
|
141
|
+
CIImage* scaled = [normalized imageByApplyingTransform:CGAffineTransformMakeScale(scale, scale)];
|
|
142
|
+
double dx = (width - scaled.extent.size.width) / 2.0 - scaled.extent.origin.x;
|
|
143
|
+
double dy = (height - scaled.extent.size.height) / 2.0 - scaled.extent.origin.y;
|
|
144
|
+
CIImage* centered = [scaled imageByApplyingTransform:CGAffineTransformMakeTranslation(dx, dy)];
|
|
145
|
+
bounds = CGRectMake(0, 0, width, height);
|
|
146
|
+
// Composited over black so the letterboxed margins render as black bars, not undefined content
|
|
147
|
+
// (a fresh CVPixelBuffer's contents aren't otherwise guaranteed zeroed).
|
|
148
|
+
fitted = [centered imageByCompositingOverImage:[CIImage imageWithColor:[CIColor colorWithRed:0 green:0 blue:0]]];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
CVPixelBufferRef pixelBuffer = nullptr;
|
|
152
|
+
CVReturn status =
|
|
153
|
+
CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA, nullptr, &pixelBuffer);
|
|
154
|
+
if (status != kCVReturnSuccess || pixelBuffer == nullptr) {
|
|
155
|
+
return nullptr;
|
|
156
|
+
}
|
|
157
|
+
[context render:fitted toCVPixelBuffer:pixelBuffer bounds:bounds colorSpace:nil];
|
|
158
|
+
return pixelBuffer;
|
|
159
|
+
}
|
|
160
|
+
|
|
84
161
|
} // namespace
|
|
85
162
|
|
|
86
163
|
bool IsKeyFrame(CMSampleBufferRef sampleBuffer) {
|
|
@@ -117,7 +194,10 @@ class VideoFrameEncoder::Impl {
|
|
|
117
194
|
onError_(std::move(onError)),
|
|
118
195
|
onEnd_(std::move(onEnd)),
|
|
119
196
|
sharedClockOrigin_(sharedClockOrigin) {
|
|
120
|
-
queue_ = dispatch_queue_create("
|
|
197
|
+
queue_ = dispatch_queue_create("io.appium.coresim.videoEncoder", DISPATCH_QUEUE_SERIAL);
|
|
198
|
+
// Persistent, like JpegStreamSession's own (see sim_jpeg_stream.mm) — a continuous stream, not
|
|
199
|
+
// CaptureScreenshot's deliberately one-shot context.
|
|
200
|
+
ciContext_ = [CIContext contextWithOptions:nil];
|
|
121
201
|
}
|
|
122
202
|
|
|
123
203
|
~Impl() { Stop(); }
|
|
@@ -133,10 +213,14 @@ class VideoFrameEncoder::Impl {
|
|
|
133
213
|
throw NSErrorException(MakeError(4, @"The device's display surface is not available yet"));
|
|
134
214
|
}
|
|
135
215
|
IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
|
|
216
|
+
// One-time blocking read so the first frame is already correctly oriented, before the
|
|
217
|
+
// periodic poll below even starts.
|
|
218
|
+
int32_t orientation = BlockingReadOrientation();
|
|
219
|
+
polledOrientation_->store(orientation, std::memory_order_relaxed);
|
|
136
220
|
// Set up synchronously (not lazily on the first Tick()) so a setup failure rejects Start()
|
|
137
221
|
// directly rather than only reaching onError, which the caller may not be listening for yet.
|
|
138
222
|
NSError* setupError = nil;
|
|
139
|
-
if (!SetUpSession(surface, &setupError)) {
|
|
223
|
+
if (!SetUpSession(surface, orientation, &setupError)) {
|
|
140
224
|
throw NSErrorException(setupError);
|
|
141
225
|
}
|
|
142
226
|
// Must be set before EncodeSurface below — both it and HandleEncodedSample measure elapsed
|
|
@@ -156,7 +240,7 @@ class VideoFrameEncoder::Impl {
|
|
|
156
240
|
running_ = true;
|
|
157
241
|
bool encoded = false;
|
|
158
242
|
try {
|
|
159
|
-
encoded = EncodeSurface(surface);
|
|
243
|
+
encoded = EncodeSurface(surface, orientation);
|
|
160
244
|
} catch (...) {
|
|
161
245
|
running_ = false;
|
|
162
246
|
VTCompressionSessionInvalidate(session_);
|
|
@@ -165,11 +249,13 @@ class VideoFrameEncoder::Impl {
|
|
|
165
249
|
throw;
|
|
166
250
|
}
|
|
167
251
|
// Only commit the seed once a frame was actually submitted — a transient pixel-buffer
|
|
168
|
-
// creation failure (EncodeSurface returning false) otherwise leaves lastSeed_
|
|
169
|
-
//
|
|
170
|
-
// the stream going silent forever on a display
|
|
252
|
+
// creation failure (EncodeSurface returning false) otherwise leaves lastSeed_/
|
|
253
|
+
// hasEncodedSinceSetup_ at their defaults, so the first Tick() sees this as still needing a
|
|
254
|
+
// frame and retries automatically instead of the stream going silent forever on a display
|
|
255
|
+
// that never changes again.
|
|
171
256
|
if (encoded) {
|
|
172
257
|
lastSeed_ = IOSurfaceGetSeed(surface);
|
|
258
|
+
hasEncodedSinceSetup_ = true;
|
|
173
259
|
}
|
|
174
260
|
|
|
175
261
|
double interval = 1.0 / std::max(options_.fps, 1.0);
|
|
@@ -184,6 +270,7 @@ class VideoFrameEncoder::Impl {
|
|
|
184
270
|
});
|
|
185
271
|
timer_ = timer;
|
|
186
272
|
dispatch_resume(timer_);
|
|
273
|
+
StartOrientationPoll();
|
|
187
274
|
}
|
|
188
275
|
|
|
189
276
|
// Callable from any thread except `queue_` itself (would deadlock on the dispatch_sync below).
|
|
@@ -191,6 +278,7 @@ class VideoFrameEncoder::Impl {
|
|
|
191
278
|
if (!running_.exchange(false)) {
|
|
192
279
|
return; // idempotent
|
|
193
280
|
}
|
|
281
|
+
StopOrientationPoll();
|
|
194
282
|
if (timer_ != nullptr) {
|
|
195
283
|
dispatch_source_cancel(timer_);
|
|
196
284
|
// Blocks until any in-flight Tick() finishes — by then running_ is already false, so it
|
|
@@ -205,12 +293,53 @@ class VideoFrameEncoder::Impl {
|
|
|
205
293
|
void RequestKeyFrame() { forceKeyFrame_ = true; }
|
|
206
294
|
|
|
207
295
|
private:
|
|
296
|
+
int32_t BlockingReadOrientation() {
|
|
297
|
+
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
|
|
298
|
+
__block int32_t result = 1;
|
|
299
|
+
ReadGuestOrientation(device_, ^(int32_t orientation) {
|
|
300
|
+
result = orientation;
|
|
301
|
+
dispatch_semaphore_signal(sema);
|
|
302
|
+
});
|
|
303
|
+
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Keeps polledOrientation_ fresh against a rotation from any source (see CLAUDE.md). Off queue_ by
|
|
308
|
+
// design, not because it's slow — Tick() must stay cheap regardless.
|
|
309
|
+
void StartOrientationPoll() {
|
|
310
|
+
std::shared_ptr<std::atomic<int32_t>> cell = polledOrientation_;
|
|
311
|
+
id device = device_;
|
|
312
|
+
dispatch_queue_t pollQueue =
|
|
313
|
+
dispatch_queue_create("io.appium.coresim.videoEncoder.orientationPoll", DISPATCH_QUEUE_SERIAL);
|
|
314
|
+
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, pollQueue);
|
|
315
|
+
constexpr int64_t kPollIntervalSeconds = 3;
|
|
316
|
+
// Starts one interval out — Start() already seeded polledOrientation_ synchronously.
|
|
317
|
+
dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, kPollIntervalSeconds * NSEC_PER_SEC),
|
|
318
|
+
kPollIntervalSeconds * NSEC_PER_SEC, NSEC_PER_SEC);
|
|
319
|
+
// `cell`, not `this` — see polledOrientation_'s own comment.
|
|
320
|
+
dispatch_source_set_event_handler(timer, ^{
|
|
321
|
+
ReadGuestOrientation(device, ^(int32_t orientation) {
|
|
322
|
+
cell->store(orientation, std::memory_order_relaxed);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
orientationPollTimer_ = timer;
|
|
326
|
+
dispatch_resume(orientationPollTimer_);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
void StopOrientationPoll() {
|
|
330
|
+
if (orientationPollTimer_ != nullptr) {
|
|
331
|
+
dispatch_source_cancel(orientationPollTimer_);
|
|
332
|
+
orientationPollTimer_ = nullptr;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
208
336
|
// Same as Stop() minus the dispatch_sync barrier — only safe from within Tick() itself, already
|
|
209
337
|
// serialized on `queue_`; would race a concurrent Tick() from any other thread.
|
|
210
338
|
void StopFromQueue() {
|
|
211
339
|
if (!running_.exchange(false)) {
|
|
212
340
|
return; // idempotent — e.g. an external Stop() already won this race
|
|
213
341
|
}
|
|
342
|
+
StopOrientationPoll();
|
|
214
343
|
if (timer_ != nullptr) {
|
|
215
344
|
dispatch_source_cancel(timer_);
|
|
216
345
|
timer_ = nullptr;
|
|
@@ -266,16 +395,46 @@ class VideoFrameEncoder::Impl {
|
|
|
266
395
|
return; // transient — the connection may not have a frame ready yet, try again next tick
|
|
267
396
|
}
|
|
268
397
|
IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
|
|
398
|
+
int32_t orientation = polledOrientation_->load(std::memory_order_relaxed);
|
|
399
|
+
int32_t rawWidth = static_cast<int32_t>(IOSurfaceGetWidth(surface));
|
|
400
|
+
int32_t rawHeight = static_cast<int32_t>(IOSurfaceGetHeight(surface));
|
|
401
|
+
if (options_.fixedFrameSize) {
|
|
402
|
+
// The session's encoded size is frozen at whatever it was on the first frame (see
|
|
403
|
+
// VideoEncoderOptions) — EncodeSurface letterboxes into it below, so a raw resize or
|
|
404
|
+
// rotation just needs this bookkeeping updated, never a session rebuild. But the raw
|
|
405
|
+
// surface's seed alone doesn't change on a rotation of an otherwise-static screen (see
|
|
406
|
+
// CLAUDE.md) — force one frame through below despite the seed-equality skip, or a
|
|
407
|
+
// rotation could go uncorrected until the screen's content happens to change on its own.
|
|
408
|
+
if (rawWidth != rawSurfaceWidth_ || rawHeight != rawSurfaceHeight_ || orientation != sessionOrientation_) {
|
|
409
|
+
hasEncodedSinceSetup_ = false;
|
|
410
|
+
}
|
|
411
|
+
rawSurfaceWidth_ = rawWidth;
|
|
412
|
+
rawSurfaceHeight_ = rawHeight;
|
|
413
|
+
sessionOrientation_ = orientation;
|
|
414
|
+
} else if (rawWidth != rawSurfaceWidth_ || rawHeight != rawSurfaceHeight_ ||
|
|
415
|
+
orientation != sessionOrientation_) {
|
|
416
|
+
// Raw resize (some CoreSimulator versions) or an orientation change — either needs the
|
|
417
|
+
// session rebuilt for the new effective dimensions.
|
|
418
|
+
NSError* resizeError = nil;
|
|
419
|
+
if (!RecreateSessionForResize(surface, orientation, &resizeError)) {
|
|
420
|
+
if (onError_) {
|
|
421
|
+
onError_(resizeError);
|
|
422
|
+
}
|
|
423
|
+
StopFromQueue();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
269
427
|
uint32_t seed = IOSurfaceGetSeed(surface);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
428
|
+
// A differently-shaped replacement surface can collide with the old seed value (confirmed
|
|
429
|
+
// empirically), so a just-rebuilt session must submit one frame before this skip applies.
|
|
430
|
+
if (seed == lastSeed_ && hasEncodedSinceSetup_) {
|
|
431
|
+
return; // unchanged since the last tick (see CLAUDE.md)
|
|
273
432
|
}
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
if (EncodeSurface(surface)) {
|
|
433
|
+
// Leave lastSeed_/hasEncodedSinceSetup_ stale on a transient EncodeSurface failure so the
|
|
434
|
+
// next tick retries.
|
|
435
|
+
if (EncodeSurface(surface, orientation)) {
|
|
278
436
|
lastSeed_ = seed;
|
|
437
|
+
hasEncodedSinceSetup_ = true;
|
|
279
438
|
}
|
|
280
439
|
} catch (const std::exception& e) {
|
|
281
440
|
// Without this, an exception here (e.g. a dropped display-proxy connection) would escape
|
|
@@ -288,9 +447,34 @@ class VideoFrameEncoder::Impl {
|
|
|
288
447
|
}
|
|
289
448
|
}
|
|
290
449
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
450
|
+
// Tears down `session_` (if any) and recreates it for `surface`'s current dimensions, rotated
|
|
451
|
+
// for `orientation`. No explicit RequestKeyFrame() needed — a fresh session's first frame is a
|
|
452
|
+
// keyframe regardless.
|
|
453
|
+
bool RecreateSessionForResize(IOSurfaceRef surface, int32_t orientation, NSError** error) {
|
|
454
|
+
if (session_ != nullptr) {
|
|
455
|
+
VTCompressionSessionCompleteFrames(session_, kCMTimeInvalid);
|
|
456
|
+
VTCompressionSessionInvalidate(session_);
|
|
457
|
+
CFRelease(session_);
|
|
458
|
+
session_ = nullptr;
|
|
459
|
+
}
|
|
460
|
+
return SetUpSession(surface, orientation, error);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
bool SetUpSession(IOSurfaceRef surface, int32_t orientation, NSError** error) {
|
|
464
|
+
int32_t rawWidth = static_cast<int32_t>(IOSurfaceGetWidth(surface));
|
|
465
|
+
int32_t rawHeight = static_cast<int32_t>(IOSurfaceGetHeight(surface));
|
|
466
|
+
rawSurfaceWidth_ = rawWidth;
|
|
467
|
+
rawSurfaceHeight_ = rawHeight;
|
|
468
|
+
sessionOrientation_ = orientation;
|
|
469
|
+
// A fresh/rebuilt session hasn't sent VideoToolbox a frame yet — Tick()'s own seed-equality
|
|
470
|
+
// skip must not apply until it has (see Tick()'s own comment on why).
|
|
471
|
+
hasEncodedSinceSetup_ = false;
|
|
472
|
+
int degrees = RotationDegreesForOrientation(orientation);
|
|
473
|
+
bool swapped = degrees == 90 || degrees == 270;
|
|
474
|
+
int32_t width = swapped ? rawHeight : rawWidth;
|
|
475
|
+
int32_t height = swapped ? rawWidth : rawHeight;
|
|
476
|
+
sessionWidth_ = width;
|
|
477
|
+
sessionHeight_ = height;
|
|
294
478
|
CMVideoCodecType codecType =
|
|
295
479
|
options_.codec == VideoStreamCodec::kHEVC ? kCMVideoCodecType_HEVC : kCMVideoCodecType_H264;
|
|
296
480
|
OSStatus status = VTCompressionSessionCreate(kCFAllocatorDefault, width, height, codecType, nullptr, nullptr,
|
|
@@ -332,11 +516,28 @@ class VideoFrameEncoder::Impl {
|
|
|
332
516
|
// Returns whether a frame was actually submitted to the encoder — false for a transient
|
|
333
517
|
// pixel-buffer creation failure the caller should retry, as opposed to a real encode failure
|
|
334
518
|
// (thrown, not returned, since that tears down the whole session).
|
|
335
|
-
bool EncodeSurface(IOSurfaceRef surface) {
|
|
519
|
+
bool EncodeSurface(IOSurfaceRef surface, int32_t orientation) {
|
|
520
|
+
int degrees = RotationDegreesForOrientation(orientation);
|
|
521
|
+
int32_t rawWidth = static_cast<int32_t>(IOSurfaceGetWidth(surface));
|
|
522
|
+
int32_t rawHeight = static_cast<int32_t>(IOSurfaceGetHeight(surface));
|
|
523
|
+
// Zero-copy only applies when the raw surface already matches the session as-is — not just
|
|
524
|
+
// degrees == 0, since fixedFrameSize mode can also need letterboxing at zero rotation (a raw
|
|
525
|
+
// resize with no orientation change).
|
|
526
|
+
bool matchesSessionAsIs = degrees == 0 && rawWidth == sessionWidth_ && rawHeight == sessionHeight_;
|
|
336
527
|
CVPixelBufferRef pixelBuffer = nullptr;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
528
|
+
if (matchesSessionAsIs) {
|
|
529
|
+
// The common case: zero-copy, exactly as before orientation correction existed.
|
|
530
|
+
CVReturn cvStatus = CVPixelBufferCreateWithIOSurface(kCFAllocatorDefault, surface, nullptr, &pixelBuffer);
|
|
531
|
+
if (cvStatus != kCVReturnSuccess || pixelBuffer == nullptr) {
|
|
532
|
+
return false; // transient — try again next tick rather than tearing down the whole session
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
int32_t canvasWidth = options_.fixedFrameSize ? sessionWidth_ : 0;
|
|
536
|
+
int32_t canvasHeight = options_.fixedFrameSize ? sessionHeight_ : 0;
|
|
537
|
+
pixelBuffer = RotatedPixelBuffer(surface, degrees, ciContext_, canvasWidth, canvasHeight);
|
|
538
|
+
if (pixelBuffer == nullptr) {
|
|
539
|
+
return false; // transient, same contract as the zero-copy path above
|
|
540
|
+
}
|
|
340
541
|
}
|
|
341
542
|
CMTime pts = CMTimeMake(static_cast<int64_t>((MonotonicSeconds() - startTime_) * 1000000), 1000000);
|
|
342
543
|
NSDictionary* frameProperties = nil;
|
|
@@ -390,13 +591,32 @@ class VideoFrameEncoder::Impl {
|
|
|
390
591
|
dispatch_queue_t queue_ = nullptr;
|
|
391
592
|
dispatch_source_t timer_ = nullptr;
|
|
392
593
|
VTCompressionSessionRef session_ = nullptr;
|
|
594
|
+
CIContext* ciContext_ = nil;
|
|
595
|
+
// The session's own post-rotation encode dimensions (what VTCompressionSessionCreate got).
|
|
596
|
+
int32_t sessionWidth_ = 0;
|
|
597
|
+
int32_t sessionHeight_ = 0;
|
|
598
|
+
// The raw IOSurface's dimensions as of the last SetUpSession — kept separate from
|
|
599
|
+
// sessionWidth_/sessionHeight_ so a rotation intentionally making them differ isn't mistaken for
|
|
600
|
+
// a raw resize.
|
|
601
|
+
int32_t rawSurfaceWidth_ = 0;
|
|
602
|
+
int32_t rawSurfaceHeight_ = 0;
|
|
603
|
+
// Orientation last baked into session_ — Tick() rebuilds when polledOrientation_ moves past this.
|
|
604
|
+
int32_t sessionOrientation_ = 1;
|
|
393
605
|
uint32_t lastSeed_ = 0;
|
|
606
|
+
// Whether session_ has submitted a frame yet — see Tick()'s seed-equality skip.
|
|
607
|
+
bool hasEncodedSinceSetup_ = false;
|
|
394
608
|
double startTime_ = 0;
|
|
395
609
|
std::atomic<bool> running_{false};
|
|
396
610
|
// Set by HandleEncodedSample (possibly off queue_) on an encoder failure, consumed by the next
|
|
397
611
|
// Tick() (on queue_) — see both for why teardown can't just happen inline there.
|
|
398
612
|
std::atomic<bool> pendingErrorTeardown_{false};
|
|
399
613
|
std::atomic<bool> forceKeyFrame_{false};
|
|
614
|
+
|
|
615
|
+
// Last orientation StartOrientationPoll read; Tick() only ever reads this cheaply. A shared_ptr
|
|
616
|
+
// (not a plain member) since a poll's guest spawn can still be in flight when Stop()/~Impl runs
|
|
617
|
+
// — its completion must never touch a possibly-dead `this` (see CLAUDE.md).
|
|
618
|
+
std::shared_ptr<std::atomic<int32_t>> polledOrientation_ = std::make_shared<std::atomic<int32_t>>(1);
|
|
619
|
+
dispatch_source_t orientationPollTimer_ = nullptr;
|
|
400
620
|
};
|
|
401
621
|
|
|
402
622
|
VideoFrameEncoder::VideoFrameEncoder(id device, VideoEncoderOptions options,
|
package/src/native-simctl.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
setDarwinNotificationState,
|
|
21
21
|
} from './commands/darwin-notification.js';
|
|
22
22
|
import {clearLocation, getEnv, openUrl, pushNotification, setLocation} from './commands/interaction.js';
|
|
23
|
+
import {startJpegStream} from './commands/jpeg-stream.js';
|
|
23
24
|
import {addCertificate, addRootCertificate, resetKeychain} from './commands/keychain.js';
|
|
24
25
|
import {
|
|
25
26
|
bootDevice,
|
|
@@ -45,9 +46,11 @@ import {
|
|
|
45
46
|
getAppearance,
|
|
46
47
|
getContentSize,
|
|
47
48
|
getIncreaseContrast,
|
|
49
|
+
getOrientation,
|
|
48
50
|
setAppearance,
|
|
49
51
|
setContentSize,
|
|
50
52
|
setIncreaseContrast,
|
|
53
|
+
setOrientation,
|
|
51
54
|
} from './commands/ui.js';
|
|
52
55
|
import {isVideoRecording, startVideoRecording, stopVideoRecording} from './commands/video-recording.js';
|
|
53
56
|
import {startVideoStream} from './commands/video-stream.js';
|
|
@@ -60,6 +63,7 @@ import './commands/app.js';
|
|
|
60
63
|
import './commands/biometric.js';
|
|
61
64
|
import './commands/darwin-notification.js';
|
|
62
65
|
import './commands/interaction.js';
|
|
66
|
+
import './commands/jpeg-stream.js';
|
|
63
67
|
import './commands/keychain.js';
|
|
64
68
|
import './commands/lifecycle.js';
|
|
65
69
|
import './commands/media.js';
|
|
@@ -227,6 +231,8 @@ Object.assign(NativeSimctl.prototype, {
|
|
|
227
231
|
setIncreaseContrast,
|
|
228
232
|
getContentSize,
|
|
229
233
|
setContentSize,
|
|
234
|
+
setOrientation,
|
|
235
|
+
getOrientation,
|
|
230
236
|
|
|
231
237
|
// permissions
|
|
232
238
|
grantPermission,
|
|
@@ -256,6 +262,7 @@ Object.assign(NativeSimctl.prototype, {
|
|
|
256
262
|
stopVideoRecording,
|
|
257
263
|
isVideoRecording,
|
|
258
264
|
startVideoStream,
|
|
265
|
+
startJpegStream,
|
|
259
266
|
|
|
260
267
|
// webinspector
|
|
261
268
|
getWebInspectorSocket,
|