@appium/coresim 1.8.0 → 1.10.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 +18 -0
- package/binding.gyp +1 -3
- 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 +1 -1
- package/lib/src/index.d.ts.map +1 -1
- package/lib/src/index.js +1 -1
- package/lib/src/index.js.map +1 -1
- package/lib/src/native-simctl.d.ts.map +1 -1
- package/lib/src/native-simctl.js +3 -1
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/types.d.ts +9 -0
- 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 +7 -14
- package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
- package/src/commands/ui.ts +27 -0
- package/src/coresim.mm +29 -0
- package/src/index.ts +1 -0
- package/src/native/av_recording.h +1 -1
- package/src/native/av_recording.mm +75 -26
- package/src/native/sim_device.h +11 -0
- package/src/native/sim_device.mm +53 -0
- package/src/native/sim_orientation.h +16 -0
- package/src/native/sim_orientation.mm +58 -0
- package/src/native/sim_screenshot.h +5 -0
- package/src/native/sim_screenshot.mm +21 -0
- package/src/native/sim_video_recording.mm +2 -43
- package/src/native/video_encoder.h +7 -0
- package/src/native/video_encoder.mm +239 -19
- package/src/native-simctl.ts +4 -0
- package/src/types.ts +10 -0
|
@@ -1,15 +1,19 @@
|
|
|
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 {
|
|
@@ -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) {
|
|
@@ -118,6 +195,9 @@ class VideoFrameEncoder::Impl {
|
|
|
118
195
|
onEnd_(std::move(onEnd)),
|
|
119
196
|
sharedClockOrigin_(sharedClockOrigin) {
|
|
120
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
|
@@ -46,9 +46,11 @@ import {
|
|
|
46
46
|
getAppearance,
|
|
47
47
|
getContentSize,
|
|
48
48
|
getIncreaseContrast,
|
|
49
|
+
getOrientation,
|
|
49
50
|
setAppearance,
|
|
50
51
|
setContentSize,
|
|
51
52
|
setIncreaseContrast,
|
|
53
|
+
setOrientation,
|
|
52
54
|
} from './commands/ui.js';
|
|
53
55
|
import {isVideoRecording, startVideoRecording, stopVideoRecording} from './commands/video-recording.js';
|
|
54
56
|
import {startVideoStream} from './commands/video-stream.js';
|
|
@@ -229,6 +231,8 @@ Object.assign(NativeSimctl.prototype, {
|
|
|
229
231
|
setIncreaseContrast,
|
|
230
232
|
getContentSize,
|
|
231
233
|
setContentSize,
|
|
234
|
+
setOrientation,
|
|
235
|
+
getOrientation,
|
|
232
236
|
|
|
233
237
|
// permissions
|
|
234
238
|
grantPermission,
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,14 @@ export enum SimDeviceState {
|
|
|
7
7
|
ShuttingDown = 4,
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** Device orientations `setOrientation` accepts — the raw GSEvent wire values (see CLAUDE.md). */
|
|
11
|
+
export enum DeviceOrientation {
|
|
12
|
+
Portrait = 1,
|
|
13
|
+
PortraitUpsideDown = 2,
|
|
14
|
+
LandscapeRight = 3,
|
|
15
|
+
LandscapeLeft = 4,
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
/** A device in the default device set, as returned by `NativeSimctl.getDevices()`/`createDevice()`. */
|
|
11
19
|
export interface SimDeviceInfo {
|
|
12
20
|
/** e.g. `"D768CB90-CBB4-4557-82F6-B89E1CD0E80B"`. */
|
|
@@ -436,6 +444,8 @@ export interface NativeDeviceHandle {
|
|
|
436
444
|
openUrl(url: string): Promise<void>;
|
|
437
445
|
setLocation(latitude: number, longitude: number): Promise<void>;
|
|
438
446
|
clearLocation(): Promise<void>;
|
|
447
|
+
setOrientation(orientation: number): Promise<void>;
|
|
448
|
+
getOrientation(): Promise<number>;
|
|
439
449
|
sendPushNotification(bundleId: string, payload: PushNotificationPayload): Promise<void>;
|
|
440
450
|
addCertificate(path: string, trustAsRoot: boolean): Promise<void>;
|
|
441
451
|
resetKeychain(): Promise<void>;
|