@appium/coresim 1.4.0 → 1.6.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -0
  3. package/binding.gyp +14 -1
  4. package/lib/src/commands/video-recording.d.ts +57 -0
  5. package/lib/src/commands/video-recording.d.ts.map +1 -0
  6. package/lib/src/commands/video-recording.js +123 -0
  7. package/lib/src/commands/video-recording.js.map +1 -0
  8. package/lib/src/commands/video-stream.d.ts +92 -0
  9. package/lib/src/commands/video-stream.d.ts.map +1 -0
  10. package/lib/src/commands/video-stream.js +242 -0
  11. package/lib/src/commands/video-stream.js.map +1 -0
  12. package/lib/src/index.d.ts +2 -1
  13. package/lib/src/index.d.ts.map +1 -1
  14. package/lib/src/index.js +1 -0
  15. package/lib/src/index.js.map +1 -1
  16. package/lib/src/native-simctl.d.ts +4 -0
  17. package/lib/src/native-simctl.d.ts.map +1 -1
  18. package/lib/src/native-simctl.js +21 -1
  19. package/lib/src/native-simctl.js.map +1 -1
  20. package/lib/src/types.d.ts +134 -0
  21. package/lib/src/types.d.ts.map +1 -1
  22. package/lib/src/utils/index.d.ts +1 -1
  23. package/lib/src/utils/index.d.ts.map +1 -1
  24. package/lib/src/utils/index.js +1 -1
  25. package/lib/src/utils/index.js.map +1 -1
  26. package/lib/src/utils/run-catching.d.ts +4 -0
  27. package/lib/src/utils/run-catching.d.ts.map +1 -1
  28. package/lib/src/utils/run-catching.js +11 -0
  29. package/lib/src/utils/run-catching.js.map +1 -1
  30. package/package.json +1 -1
  31. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  32. package/src/commands/video-recording.ts +158 -0
  33. package/src/commands/video-stream.ts +283 -0
  34. package/src/coresim.mm +708 -1
  35. package/src/index.ts +4 -0
  36. package/src/native/audio_encoder.h +65 -0
  37. package/src/native/audio_encoder.mm +270 -0
  38. package/src/native/av_recording.h +52 -0
  39. package/src/native/av_recording.mm +424 -0
  40. package/src/native/av_stream.h +70 -0
  41. package/src/native/av_stream.mm +217 -0
  42. package/src/native/monotonic_clock.h +20 -0
  43. package/src/native/sim_audio_tap.h +67 -0
  44. package/src/native/sim_audio_tap.mm +350 -0
  45. package/src/native/sim_process.h +10 -0
  46. package/src/native/sim_process.mm +81 -18
  47. package/src/native/sim_screenshot.h +10 -0
  48. package/src/native/sim_screenshot.mm +11 -6
  49. package/src/native/sim_video_recording.h +27 -0
  50. package/src/native/sim_video_recording.mm +87 -0
  51. package/src/native/sim_video_stream.h +64 -0
  52. package/src/native/sim_video_stream.mm +71 -0
  53. package/src/native/video_encoder.h +76 -0
  54. package/src/native/video_encoder.mm +417 -0
  55. package/src/native-simctl.ts +22 -1
  56. package/src/types.ts +149 -0
  57. package/src/utils/index.ts +1 -1
  58. package/src/utils/run-catching.ts +11 -0
@@ -0,0 +1,417 @@
1
+ #include "video_encoder.h"
2
+
3
+ #import <CoreVideo/CoreVideo.h>
4
+ #import <IOSurface/IOSurface.h>
5
+ #import <VideoToolbox/VideoToolbox.h>
6
+
7
+ #include <algorithm>
8
+ #include <atomic>
9
+
10
+ #include "monotonic_clock.h"
11
+ #include "nserror_bridge.h"
12
+ #include "safe_dispatch.h"
13
+ #include "sim_screenshot.h"
14
+
15
+ namespace coresim {
16
+
17
+ namespace {
18
+
19
+ NSString* const kVideoEncoderErrorDomain = @"com.appium.coresim.VideoEncoder";
20
+
21
+ NSError* MakeError(NSInteger code, NSString* message) {
22
+ return [NSError errorWithDomain:kVideoEncoderErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
23
+ }
24
+
25
+ NSError* MakeStatusError(NSInteger code, NSString* what, OSStatus status) {
26
+ return MakeError(code, [NSString stringWithFormat:@"%@ (OSStatus %d)", what, static_cast<int>(status)]);
27
+ }
28
+
29
+ void AppendAnnexB(std::vector<uint8_t>& out, const uint8_t* nal, size_t length) {
30
+ static const uint8_t kStartCode[4] = {0, 0, 0, 1};
31
+ out.insert(out.end(), kStartCode, kStartCode + 4);
32
+ out.insert(out.end(), nal, nal + length);
33
+ }
34
+
35
+ // CMBlockBufferGetDataPointer's pointer only covers the contiguous region starting at the given
36
+ // offset, which for a segmented buffer (multiple backing memory blocks — CoreMedia's documented
37
+ // contract, not just a VideoToolbox implementation detail) can be far shorter than totalLength;
38
+ // reading up to totalLength through it would run past that region. CopyDataBytes stitches
39
+ // segments together into a caller-owned, guaranteed-contiguous copy instead.
40
+ void AppendSampleBufferNALs(std::vector<uint8_t>& out, CMSampleBufferRef sampleBuffer) {
41
+ CMBlockBufferRef block = CMSampleBufferGetDataBuffer(sampleBuffer);
42
+ if (block == nullptr) {
43
+ return;
44
+ }
45
+ size_t totalLength = CMBlockBufferGetDataLength(block);
46
+ if (totalLength == 0) {
47
+ return;
48
+ }
49
+ std::vector<uint8_t> data(totalLength);
50
+ if (CMBlockBufferCopyDataBytes(block, 0, totalLength, data.data()) != kCMBlockBufferNoErr) {
51
+ return;
52
+ }
53
+ const uint8_t* dataPointer = data.data();
54
+ size_t offset = 0;
55
+ while (offset + 4 <= totalLength) {
56
+ uint32_t nalLength = (static_cast<uint32_t>(dataPointer[offset]) << 24) |
57
+ (static_cast<uint32_t>(dataPointer[offset + 1]) << 16) |
58
+ (static_cast<uint32_t>(dataPointer[offset + 2]) << 8) | dataPointer[offset + 3];
59
+ offset += 4;
60
+ if (nalLength == 0 || offset + nalLength > totalLength) {
61
+ break;
62
+ }
63
+ AppendAnnexB(out, dataPointer + offset, nalLength);
64
+ offset += nalLength;
65
+ }
66
+ }
67
+
68
+ using ParameterSetAtIndexFn = OSStatus (*)(CMFormatDescriptionRef, size_t, const uint8_t**, size_t*, size_t*, int*);
69
+
70
+ void AppendParameterSets(std::vector<uint8_t>& out, CMFormatDescriptionRef format, ParameterSetAtIndexFn getAtIndex) {
71
+ size_t count = 0;
72
+ if (getAtIndex(format, 0, nullptr, nullptr, &count, nullptr) != noErr) {
73
+ return;
74
+ }
75
+ for (size_t i = 0; i < count; i++) {
76
+ const uint8_t* bytes = nullptr;
77
+ size_t size = 0;
78
+ if (getAtIndex(format, i, &bytes, &size, nullptr, nullptr) == noErr) {
79
+ AppendAnnexB(out, bytes, size);
80
+ }
81
+ }
82
+ }
83
+
84
+ } // namespace
85
+
86
+ bool IsKeyFrame(CMSampleBufferRef sampleBuffer) {
87
+ CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, false);
88
+ if (attachments == nullptr || CFArrayGetCount(attachments) == 0) {
89
+ return true;
90
+ }
91
+ CFDictionaryRef attachment = static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(attachments, 0));
92
+ return !CFDictionaryContainsKey(attachment, kCMSampleAttachmentKey_NotSync);
93
+ }
94
+
95
+ void RepackAsAnnexB(std::vector<uint8_t>& out, CMSampleBufferRef sampleBuffer, bool isKeyFrame,
96
+ VideoStreamCodec codec) {
97
+ if (isKeyFrame) {
98
+ CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
99
+ if (format != nullptr) {
100
+ if (codec == VideoStreamCodec::kHEVC) {
101
+ AppendParameterSets(out, format, CMVideoFormatDescriptionGetHEVCParameterSetAtIndex);
102
+ } else {
103
+ AppendParameterSets(out, format, CMVideoFormatDescriptionGetH264ParameterSetAtIndex);
104
+ }
105
+ }
106
+ }
107
+ AppendSampleBufferNALs(out, sampleBuffer);
108
+ }
109
+
110
+ class VideoFrameEncoder::Impl {
111
+ public:
112
+ Impl(id device, VideoEncoderOptions options, std::function<void(CMSampleBufferRef)> onSample,
113
+ std::function<void(NSError*)> onError, std::function<void()> onEnd, const double* sharedClockOrigin)
114
+ : device_(device),
115
+ options_(options),
116
+ onSample_(std::move(onSample)),
117
+ onError_(std::move(onError)),
118
+ onEnd_(std::move(onEnd)),
119
+ sharedClockOrigin_(sharedClockOrigin) {
120
+ queue_ = dispatch_queue_create("com.appium.coresim.videoEncoder", DISPATCH_QUEUE_SERIAL);
121
+ }
122
+
123
+ ~Impl() { Stop(); }
124
+
125
+ void Start() {
126
+ NSError* error = nil;
127
+ id descriptor = ResolveCaptureDisplay(device_, options_.displayId, &error);
128
+ if (descriptor == nil) {
129
+ throw NSErrorException(error);
130
+ }
131
+ id surfaceObj = CurrentDisplaySurface(descriptor);
132
+ if (surfaceObj == nil) {
133
+ throw NSErrorException(MakeError(4, @"The device's display surface is not available yet"));
134
+ }
135
+ IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
136
+ // Set up synchronously (not lazily on the first Tick()) so a setup failure rejects Start()
137
+ // directly rather than only reaching onError, which the caller may not be listening for yet.
138
+ NSError* setupError = nil;
139
+ if (!SetUpSession(surface, &setupError)) {
140
+ throw NSErrorException(setupError);
141
+ }
142
+ // Must be set before EncodeSurface below — both it and HandleEncodedSample measure elapsed
143
+ // time from this. A caller-supplied origin (see the constructor) is used as-is, not offset
144
+ // further — the gap between it being captured and this line running is itself the correct,
145
+ // meaningful startup latency to bake into this encoder's PTS zero, for sync with a peer
146
+ // AudioEncoder given the same origin (av_recording.h/av_stream.h).
147
+ startTime_ = sharedClockOrigin_ != nullptr ? *sharedClockOrigin_ : MonotonicSeconds();
148
+ // Encode immediately rather than waiting for a *changed* seed on the first tick, or the
149
+ // stream would stay silent until the display changes again. running_ is set true before this
150
+ // call (not after), since VTCompressionSessionEncodeFrame's output callback can in principle
151
+ // fire on another thread before this one returns — HandleEncodedSample discards samples while
152
+ // running_ is false, which would otherwise silently drop the stream's very first (keyframe)
153
+ // sample. A failure resets it and tears session_ down itself here, rather than going through
154
+ // Stop()/onEnd_ (see coresim.mm — onEnd_ firing this early would double-release its
155
+ // ThreadSafeFunctions).
156
+ running_ = true;
157
+ bool encoded = false;
158
+ try {
159
+ encoded = EncodeSurface(surface);
160
+ } catch (...) {
161
+ running_ = false;
162
+ VTCompressionSessionInvalidate(session_);
163
+ CFRelease(session_);
164
+ session_ = nullptr;
165
+ throw;
166
+ }
167
+ // Only commit the seed once a frame was actually submitted — a transient pixel-buffer
168
+ // creation failure (EncodeSurface returning false) otherwise leaves lastSeed_ at its default
169
+ // 0, so the first Tick() sees the real seed as "changed" and retries automatically instead of
170
+ // the stream going silent forever on a display that never changes again.
171
+ if (encoded) {
172
+ lastSeed_ = IOSurfaceGetSeed(surface);
173
+ }
174
+
175
+ double interval = 1.0 / std::max(options_.fps, 1.0);
176
+ dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue_);
177
+ dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 0),
178
+ static_cast<uint64_t>(interval * NSEC_PER_SEC),
179
+ static_cast<uint64_t>(interval * NSEC_PER_SEC / 10));
180
+ // `this` outlives the timer: Stop()/StopFromQueue() always drain or outrun it before `this`
181
+ // can be destroyed (see their comments below).
182
+ dispatch_source_set_event_handler(timer, ^{
183
+ Tick();
184
+ });
185
+ timer_ = timer;
186
+ dispatch_resume(timer_);
187
+ }
188
+
189
+ // Callable from any thread except `queue_` itself (would deadlock on the dispatch_sync below).
190
+ void Stop() {
191
+ if (!running_.exchange(false)) {
192
+ return; // idempotent
193
+ }
194
+ if (timer_ != nullptr) {
195
+ dispatch_source_cancel(timer_);
196
+ // Blocks until any in-flight Tick() finishes — by then running_ is already false, so it
197
+ // won't touch session_ again.
198
+ dispatch_sync(queue_, ^{
199
+ });
200
+ timer_ = nullptr;
201
+ }
202
+ TearDownSessionAndFireEnd();
203
+ }
204
+
205
+ void RequestKeyFrame() { forceKeyFrame_ = true; }
206
+
207
+ private:
208
+ // Same as Stop() minus the dispatch_sync barrier — only safe from within Tick() itself, already
209
+ // serialized on `queue_`; would race a concurrent Tick() from any other thread.
210
+ void StopFromQueue() {
211
+ if (!running_.exchange(false)) {
212
+ return; // idempotent — e.g. an external Stop() already won this race
213
+ }
214
+ if (timer_ != nullptr) {
215
+ dispatch_source_cancel(timer_);
216
+ timer_ = nullptr;
217
+ }
218
+ TearDownSessionAndFireEnd();
219
+ }
220
+
221
+ void TearDownSessionAndFireEnd() {
222
+ if (session_ != nullptr) {
223
+ // Flushes and blocks until every already-submitted frame's callback has returned — without
224
+ // this, a frame submitted just before Stop() could fire after onEnd_ releases whatever
225
+ // resources the caller tied to it (e.g. ThreadSafeFunctions — see CLAUDE.md).
226
+ VTCompressionSessionCompleteFrames(session_, kCMTimeInvalid);
227
+ VTCompressionSessionInvalidate(session_);
228
+ CFRelease(session_);
229
+ session_ = nullptr;
230
+ }
231
+ if (onEnd_) {
232
+ onEnd_();
233
+ }
234
+ }
235
+
236
+ void Tick() {
237
+ if (!running_) {
238
+ return;
239
+ }
240
+ if (pendingErrorTeardown_) {
241
+ // HandleEncodedSample (below) can run on VideoToolbox's own callback thread, where it's
242
+ // unsafe to tear down directly — StopFromQueue()/CompleteFrames() are only safe already
243
+ // serialized on `queue_` (here), and CompleteFrames specifically would deadlock waiting on
244
+ // its own still-executing callback. It sets this flag instead; picked up on the very next
245
+ // tick (queue_-serialized, safe) rather than via a raw cross-thread dispatch, since nothing
246
+ // here can outlive `this` the way a block captured on another thread otherwise could.
247
+ StopFromQueue();
248
+ return;
249
+ }
250
+ @autoreleasepool {
251
+ try {
252
+ // Re-resolved every tick (like CaptureScreenshot does), not cached once in Start(), so a
253
+ // deleted device or disconnected display surfaces a real error instead of Tick() quietly
254
+ // doing nothing forever.
255
+ NSError* resolveError = nil;
256
+ id descriptor = ResolveCaptureDisplay(device_, options_.displayId, &resolveError);
257
+ if (descriptor == nil) {
258
+ if (onError_) {
259
+ onError_(resolveError);
260
+ }
261
+ StopFromQueue();
262
+ return;
263
+ }
264
+ id surfaceObj = CurrentDisplaySurface(descriptor);
265
+ if (surfaceObj == nil) {
266
+ return; // transient — the connection may not have a frame ready yet, try again next tick
267
+ }
268
+ IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
269
+ uint32_t seed = IOSurfaceGetSeed(surface);
270
+ if (seed == lastSeed_) {
271
+ return; // unchanged since the last tick — mirrors CoreSimulator's own recorder, which
272
+ // only encodes a frame when the display actually changes (see CLAUDE.md)
273
+ }
274
+ // Only commit the new seed once EncodeSurface actually submits it — a transient failure
275
+ // (pixel-buffer creation) must leave lastSeed_ stale so the next tick retries this same
276
+ // frame instead of silently going quiet until the display changes again.
277
+ if (EncodeSurface(surface)) {
278
+ lastSeed_ = seed;
279
+ }
280
+ } catch (const std::exception& e) {
281
+ // Without this, an exception here (e.g. a dropped display-proxy connection) would escape
282
+ // this bare GCD timer handler uncaught and crash the whole process (see CLAUDE.md).
283
+ if (onError_) {
284
+ onError_(MakeError(3, [NSString stringWithFormat:@"Video encoding failed: %s", e.what()]));
285
+ }
286
+ StopFromQueue();
287
+ }
288
+ }
289
+ }
290
+
291
+ bool SetUpSession(IOSurfaceRef surface, NSError** error) {
292
+ int32_t width = static_cast<int32_t>(IOSurfaceGetWidth(surface));
293
+ int32_t height = static_cast<int32_t>(IOSurfaceGetHeight(surface));
294
+ CMVideoCodecType codecType =
295
+ options_.codec == VideoStreamCodec::kHEVC ? kCMVideoCodecType_HEVC : kCMVideoCodecType_H264;
296
+ OSStatus status = VTCompressionSessionCreate(kCFAllocatorDefault, width, height, codecType, nullptr, nullptr,
297
+ kCFAllocatorDefault, OutputCallback, this, &session_);
298
+ if (status != noErr) {
299
+ *error = MakeStatusError(1, @"Failed to create a VTCompressionSession", status);
300
+ return false;
301
+ }
302
+ // Clamped like Start()'s timer interval — an unvalidated 0 here would set MaxKeyFrameInterval
303
+ // to an out-of-spec value.
304
+ double fps = std::max(options_.fps, 1.0);
305
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
306
+ if (status == noErr) {
307
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse);
308
+ }
309
+ if (status == noErr) {
310
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_AverageBitRate,
311
+ (__bridge CFNumberRef) @(options_.bitrate));
312
+ }
313
+ if (status == noErr) {
314
+ status =
315
+ VTSessionSetProperty(session_, kVTCompressionPropertyKey_ExpectedFrameRate, (__bridge CFNumberRef) @(fps));
316
+ }
317
+ if (status == noErr) {
318
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_MaxKeyFrameInterval,
319
+ (__bridge CFNumberRef) @(static_cast<int>(fps * 2)));
320
+ }
321
+ if (status != noErr) {
322
+ *error = MakeStatusError(2, @"Failed to configure the VTCompressionSession", status);
323
+ VTCompressionSessionInvalidate(session_);
324
+ CFRelease(session_);
325
+ session_ = nullptr;
326
+ return false;
327
+ }
328
+ VTCompressionSessionPrepareToEncodeFrames(session_);
329
+ return true;
330
+ }
331
+
332
+ // Returns whether a frame was actually submitted to the encoder — false for a transient
333
+ // pixel-buffer creation failure the caller should retry, as opposed to a real encode failure
334
+ // (thrown, not returned, since that tears down the whole session).
335
+ bool EncodeSurface(IOSurfaceRef surface) {
336
+ CVPixelBufferRef pixelBuffer = nullptr;
337
+ CVReturn cvStatus = CVPixelBufferCreateWithIOSurface(kCFAllocatorDefault, surface, nullptr, &pixelBuffer);
338
+ if (cvStatus != kCVReturnSuccess || pixelBuffer == nullptr) {
339
+ return false; // transient — try again next tick rather than tearing down the whole session
340
+ }
341
+ CMTime pts = CMTimeMake(static_cast<int64_t>((MonotonicSeconds() - startTime_) * 1000000), 1000000);
342
+ NSDictionary* frameProperties = nil;
343
+ if (forceKeyFrame_.exchange(false)) {
344
+ frameProperties = @{(__bridge NSString*)kVTEncodeFrameOptionKey_ForceKeyFrame : @YES};
345
+ }
346
+ OSStatus status = VTCompressionSessionEncodeFrame(session_, pixelBuffer, pts, kCMTimeInvalid,
347
+ (__bridge CFDictionaryRef)frameProperties, nullptr, nullptr);
348
+ CVPixelBufferRelease(pixelBuffer);
349
+ if (status != noErr) {
350
+ throw std::runtime_error([[NSString stringWithFormat:@"VTCompressionSessionEncodeFrame failed (OSStatus %d)",
351
+ static_cast<int>(status)] UTF8String]);
352
+ }
353
+ return true;
354
+ }
355
+
356
+ static void OutputCallback(void* outputCallbackRefCon, void* /*sourceFrameRefCon*/, OSStatus status,
357
+ VTEncodeInfoFlags /*infoFlags*/, CMSampleBufferRef sampleBuffer) {
358
+ static_cast<Impl*>(outputCallbackRefCon)->HandleEncodedSample(status, sampleBuffer);
359
+ }
360
+
361
+ void HandleEncodedSample(OSStatus status, CMSampleBufferRef sampleBuffer) {
362
+ if (!running_) {
363
+ return;
364
+ }
365
+ if (status != noErr) {
366
+ // Only the first failure is reported — pendingErrorTeardown_ doubles as the report-once
367
+ // gate, since Tick() (the only place that consumes it) only ever needs to see it once too.
368
+ if (!pendingErrorTeardown_.exchange(true)) {
369
+ if (onError_) {
370
+ onError_(MakeStatusError(3, @"VideoToolbox reported an encoding failure", status));
371
+ }
372
+ }
373
+ return;
374
+ }
375
+ if (sampleBuffer == nullptr) {
376
+ return;
377
+ }
378
+ if (onSample_) {
379
+ onSample_(sampleBuffer);
380
+ }
381
+ }
382
+
383
+ id device_;
384
+ VideoEncoderOptions options_;
385
+ std::function<void(CMSampleBufferRef)> onSample_;
386
+ std::function<void(NSError*)> onError_;
387
+ std::function<void()> onEnd_;
388
+ const double* sharedClockOrigin_;
389
+
390
+ dispatch_queue_t queue_ = nullptr;
391
+ dispatch_source_t timer_ = nullptr;
392
+ VTCompressionSessionRef session_ = nullptr;
393
+ uint32_t lastSeed_ = 0;
394
+ double startTime_ = 0;
395
+ std::atomic<bool> running_{false};
396
+ // Set by HandleEncodedSample (possibly off queue_) on an encoder failure, consumed by the next
397
+ // Tick() (on queue_) — see both for why teardown can't just happen inline there.
398
+ std::atomic<bool> pendingErrorTeardown_{false};
399
+ std::atomic<bool> forceKeyFrame_{false};
400
+ };
401
+
402
+ VideoFrameEncoder::VideoFrameEncoder(id device, VideoEncoderOptions options,
403
+ std::function<void(CMSampleBufferRef)> onSample,
404
+ std::function<void(NSError*)> onError, std::function<void()> onEnd,
405
+ const double* sharedClockOrigin)
406
+ : impl_(std::make_unique<Impl>(device, options, std::move(onSample), std::move(onError), std::move(onEnd),
407
+ sharedClockOrigin)) {}
408
+
409
+ VideoFrameEncoder::~VideoFrameEncoder() = default;
410
+
411
+ void VideoFrameEncoder::Start() { impl_->Start(); }
412
+
413
+ void VideoFrameEncoder::Stop() { impl_->Stop(); }
414
+
415
+ void VideoFrameEncoder::RequestKeyFrame() { impl_->RequestKeyFrame(); }
416
+
417
+ } // namespace coresim
@@ -49,6 +49,8 @@ import {
49
49
  setContentSize,
50
50
  setIncreaseContrast,
51
51
  } from './commands/ui.js';
52
+ import {isVideoRecording, startVideoRecording, stopVideoRecording} from './commands/video-recording.js';
53
+ import {startVideoStream} from './commands/video-stream.js';
52
54
  import {getWebInspectorSocket} from './commands/webinspector.js';
53
55
  // Bare re-imports so `declare module './native-simctl.js'` augmentations in the command modules
54
56
  // (which add their methods to NativeSimctl's type) reach downstream consumers' emitted .d.ts
@@ -68,6 +70,8 @@ import './commands/process.js';
68
70
  import './commands/screenshot.js';
69
71
  import './commands/spawn.js';
70
72
  import './commands/ui.js';
73
+ import './commands/video-recording.js';
74
+ import './commands/video-stream.js';
71
75
  import './commands/webinspector.js';
72
76
  import {NativeSimUnavailableError} from './errors.js';
73
77
  import type {
@@ -247,6 +251,12 @@ Object.assign(NativeSimctl.prototype, {
247
251
  getScreenshot,
248
252
  getDisplays,
249
253
 
254
+ // video recording
255
+ startVideoRecording,
256
+ stopVideoRecording,
257
+ isVideoRecording,
258
+ startVideoStream,
259
+
250
260
  // webinspector
251
261
  getWebInspectorSocket,
252
262
 
@@ -271,7 +281,18 @@ const loadNative = util.memoize(function loadNative(): NativeCoreSimModule {
271
281
  'n/a',
272
282
  );
273
283
  }
274
- return require('node-gyp-build')(getPkgRoot()) as NativeCoreSimModule;
284
+ const native = require('node-gyp-build')(getPkgRoot()) as NativeCoreSimModule;
285
+ // A forgotten (never explicitly stopped) AV recording/stream otherwise silently loses data —
286
+ // or just leaks a live encoder — the instant a caller force-exits via `process.exit()`, since
287
+ // Node's own cleanup hooks (coresim.mm's CleanupActiveSessions, registered against the exact
288
+ // same condition) are confirmed to NOT run in that path, only on a natural empty-event-loop
289
+ // exit or a Worker's own termination. `process.on('exit', ...)` does fire for `process.exit()`
290
+ // too, and (per Node's own contract) may run synchronous code — flushActiveSessions() qualifies:
291
+ // it's a single blocking native call, not new async JS work. Registered once, lazily, here
292
+ // rather than at module import time, so merely importing this package on a non-macOS platform
293
+ // never touches `process` for something it'll never need.
294
+ process.on('exit', () => native.flushActiveSessions());
295
+ return native;
275
296
  });
276
297
 
277
298
  const DEFAULT_DEVELOPER_DIR_TIMEOUT_MS = 15_000;
package/src/types.ts CHANGED
@@ -128,6 +128,90 @@ export interface ScreenshotOptions {
128
128
  quality?: number;
129
129
  }
130
130
 
131
+ /** Options for `NativeSimctl.startVideoRecording`. */
132
+ export interface VideoRecordingOptions {
133
+ /**
134
+ * Which display to record, by `id` from `getDisplays()`. Defaults to the primary display
135
+ * (falling back to the first renderable display if none is primary, e.g. tvOS).
136
+ */
137
+ displayId?: string;
138
+ /** Video codec — `'h264'` (default) or `'hevc'`. */
139
+ codec?: 'h264' | 'hevc';
140
+ /**
141
+ * For a non-rectangular display (e.g. a Dynamic Island cutout): `'ignored'` (default) saves the
142
+ * unmasked framebuffer, `'black'` renders the mask black, `'alpha'` is not supported and
143
+ * behaves like `'black'`. Only applies when neither `audio` nor `fps` is set — see their doc
144
+ * comments.
145
+ */
146
+ mask?: 'ignored' | 'alpha' | 'black';
147
+ /**
148
+ * Also capture the device's audio into the same file, muxed as a second track. Defaults to
149
+ * `false`. Requires macOS 14.2+ (Core Audio process taps), the host's "System Audio Recording
150
+ * Only" privacy permission (System Settings > Privacy & Security — cannot be granted
151
+ * programmatically; a denial isn't a thrown error, it surfaces as a silent, audio-less/near-
152
+ * silent recording), a default audio output device on the host, and a booted device that has
153
+ * produced audio at least once. See the README's "Screen capture" section for the full
154
+ * requirements list and known failure modes.
155
+ *
156
+ * Like an explicit `fps`, this switches the implementation to this addon's own VideoToolbox +
157
+ * Core Audio encoders instead of CoreSimulator's private recorder, which can't mux audio.
158
+ */
159
+ audio?: boolean;
160
+ /**
161
+ * Max frames/sec to poll the framebuffer at — see {@link VideoStreamOptions} `fps` for the
162
+ * identical semantics. Meaningless against CoreSimulator's private recorder (it captures on its
163
+ * own cadence, not one we poll), so setting `fps` — even without `audio` — switches this
164
+ * recording to the same own-encoder implementation `audio` does. That switch costs `mask`
165
+ * support, which only the private recorder implements.
166
+ */
167
+ fps?: number;
168
+ /** Target average bitrate, in bits/sec. Respected on either implementation. */
169
+ bitrate?: number;
170
+ }
171
+
172
+ /** Options for `NativeSimctl.startVideoStream`. */
173
+ export interface VideoStreamOptions {
174
+ /**
175
+ * Which display to stream, by `id` from `getDisplays()`. Defaults to the primary display
176
+ * (falling back to the first renderable display if none is primary, e.g. tvOS).
177
+ */
178
+ displayId?: string;
179
+ /** Video codec — `'h264'` (default) or `'hevc'`. */
180
+ codec?: 'h264' | 'hevc';
181
+ /**
182
+ * Max frames/sec to poll the framebuffer at — an unchanged frame is never re-encoded, so this
183
+ * is an upper bound, not a guarantee. Must be >= 1. Defaults to 15.
184
+ */
185
+ fps?: number;
186
+ /** Target average bitrate, in bits/sec. Defaults to 2,000,000 (2 Mbps). */
187
+ bitrate?: number;
188
+ /**
189
+ * Also stream the device's audio, interleaved into the same `accessUnits()` sequence. Defaults
190
+ * to `false`. Same requirements and failure modes as {@link VideoRecordingOptions.audio} — see
191
+ * its doc comment and the README's "Screen capture" section.
192
+ */
193
+ audio?: boolean;
194
+ }
195
+
196
+ /**
197
+ * One encoded unit from `VideoStream.accessUnits()`, discriminated by `track`: a video unit
198
+ * (Annex-B NAL units — a keyframe's `data` has parameter sets, SPS/PPS or VPS/SPS/PPS for HEVC,
199
+ * prepended, so it's self-decodable alone) or, when {@link VideoStreamOptions.audio} was set, an
200
+ * interleaved audio unit (an ADTS-framed AAC-LC packet — the 7-byte ADTS header carries sample
201
+ * rate/channel count itself, so no separate decoder-config exchange is needed; always
202
+ * independently decodable, so `isKeyFrame` is always `true`). Without `audio`, every unit has
203
+ * `track: 'video'`.
204
+ */
205
+ export interface VideoAccessUnit {
206
+ track: 'video' | 'audio';
207
+ data: Buffer;
208
+ isKeyFrame: boolean;
209
+ /** Monotonically increasing per track, starting at 0 — independent between `'video'` and `'audio'`. */
210
+ sequence: number;
211
+ /** Microseconds since the stream started, on one shared clock across both tracks. */
212
+ timestampMicros: number;
213
+ }
214
+
131
215
  /**
132
216
  * Options for `NativeSimctl.spawnProcess`, passed through to CoreSimulator's
133
217
  * `spawnWithPath:options:terminationQueue:terminationHandler:error:`. Only keys confirmed
@@ -230,6 +314,41 @@ export interface NativeSpawnResult {
230
314
  */
231
315
  export type NativeSpawnExitCallback = (code: number | null, signal: number | null) => void;
232
316
 
317
+ /** Raw shape of an access unit as the native addon delivers it — see {@link VideoAccessUnit}. */
318
+ export interface NativeVideoAccessUnit {
319
+ track: 'video' | 'audio';
320
+ data: Buffer;
321
+ isKeyFrame: boolean;
322
+ sequence: number;
323
+ timestampMicros: number;
324
+ }
325
+
326
+ export type NativeVideoAccessUnitCallback = (unit: NativeVideoAccessUnit) => void;
327
+ export type NativeVideoErrorCallback = (err: Error) => void;
328
+
329
+ /**
330
+ * A live encoder session, wrapped by `coresim.mm`'s `NativeVideoStream` (video only) or
331
+ * `NativeAVStream` (`audio: true` — see {@link VideoStreamOptions}) — either way, what
332
+ * `NativeDeviceHandle.startVideoStream()` resolves to; the two native wrapper classes expose the
333
+ * identical shape below, so callers never need to know which one they got.
334
+ */
335
+ export interface NativeVideoStreamHandle {
336
+ stop(): Promise<void>;
337
+ /** Forces the next encoded video frame to be a keyframe — trivial in-memory flag, so synchronous. */
338
+ requestKeyFrame(): void;
339
+ }
340
+
341
+ /**
342
+ * A live recording, wrapped by `coresim.mm`'s `NativePrivateRecordingHandle` (video only,
343
+ * addressing CoreSimulator's own internally-tracked private recorder) or `NativeAVRecording`
344
+ * (`audio: true` — see {@link VideoRecordingOptions}, a real local resource with no server-side
345
+ * counterpart) — either way, what `NativeDeviceHandle.startVideoRecording()` resolves to.
346
+ */
347
+ export interface NativeVideoRecordingHandle {
348
+ /** Resolves once the output file has been finalized on disk and is safe to read. */
349
+ stop(): Promise<void>;
350
+ }
351
+
233
352
  /** A `SimDevice`, wrapped by `coresim.mm`'s `NativeDevice` — what `NativeSimctl`'s `_findDevice()` resolves to. */
234
353
  export interface NativeDeviceHandle {
235
354
  // Trivial in-memory accessors — kept synchronous on the native side (see coresim.mm), never a
@@ -278,6 +397,29 @@ export interface NativeDeviceHandle {
278
397
  getWebInspectorSocket(): Promise<string>;
279
398
  screenshot(options?: {format?: 'png' | 'jpeg'; displayId?: string; quality?: number}): Promise<Buffer>;
280
399
  getDisplays(): Promise<SimDisplayInfo[]>;
400
+ // `mask` only applies without `audio`/`fps`; `bitrate` applies either way — see
401
+ // VideoRecordingOptions's own doc comments for why. `onError` is only ever invoked on the
402
+ // `audio`/`fps` (own-encoder) path — a live mid-recording failure, which the private recorder
403
+ // has no channel to report.
404
+ startVideoRecording(
405
+ outputFile: string,
406
+ options:
407
+ | {
408
+ displayId?: string;
409
+ codec?: 'h264' | 'hevc';
410
+ mask?: 'ignored' | 'alpha' | 'black';
411
+ audio?: boolean;
412
+ fps?: number;
413
+ bitrate?: number;
414
+ }
415
+ | undefined,
416
+ onError: NativeVideoErrorCallback,
417
+ ): Promise<NativeVideoRecordingHandle>;
418
+ startVideoStream(
419
+ options: {displayId?: string; codec?: 'h264' | 'hevc'; fps?: number; bitrate?: number; audio?: boolean} | undefined,
420
+ onAccessUnit: NativeVideoAccessUnitCallback,
421
+ onError: NativeVideoErrorCallback,
422
+ ): Promise<NativeVideoStreamHandle>;
281
423
  spawn(path: string, options: SpawnOptions | undefined, onExit: NativeSpawnExitCallback): Promise<NativeSpawnResult>;
282
424
  }
283
425
 
@@ -300,4 +442,11 @@ export interface NativeServiceContextHandle {
300
442
  export interface NativeCoreSimModule {
301
443
  sharedServiceContext(developerDir: string): Promise<NativeServiceContextHandle>;
302
444
  frameworkVersion(): Promise<string>;
445
+ /**
446
+ * Synchronously (not a Promise) stops every still-live video/AV stream or AV recording, blocking
447
+ * until each has released its resources. Meant to be called from a `process.on('exit', ...)`
448
+ * listener (see native-simctl.ts) — cleanup hooks alone don't run under `process.exit()` on the
449
+ * main process/thread, only on a natural empty-event-loop exit or a Worker's own termination.
450
+ */
451
+ flushActiveSessions(): void;
303
452
  }
@@ -1,2 +1,2 @@
1
1
  export {getPkgRoot, PACKAGE_NAME} from './pkg-root.js';
2
- export {runCatchingAsync} from './run-catching.js';
2
+ export {runCatchingAsync, toTypedError} from './run-catching.js';
@@ -8,3 +8,14 @@ export async function runCatchingAsync<T>(fn: () => Promise<T>): Promise<T> {
8
8
  wrapNativeError(err);
9
9
  }
10
10
  }
11
+
12
+ /** `wrapNativeError` always throws — this just gets its thrown value back as a plain return, for
13
+ * a live callback (e.g. a stream/recording's `onError`) that needs to emit or log a typed error
14
+ * rather than raise it. */
15
+ export function toTypedError(err: unknown): Error {
16
+ try {
17
+ wrapNativeError(err);
18
+ } catch (wrapped) {
19
+ return wrapped as Error;
20
+ }
21
+ }