serve-sim 0.1.49-beta.141.1 → 0.1.49
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/README.md +11 -0
- package/Sources/SimCameraInjector/SimCamFakes.m +12 -0
- package/Sources/SimCameraInjector/SimCamMovieOutput.h +17 -0
- package/Sources/SimCameraInjector/SimCamMovieOutput.m +211 -0
- package/Sources/SimCameraInjector/SimCamMovieRecorder.h +30 -0
- package/Sources/SimCameraInjector/SimCamMovieRecorder.m +270 -0
- package/Sources/SimCameraInjector/SimCamSwizzles.h +11 -0
- package/Sources/SimCameraInjector/SimCamSwizzles.m +211 -64
- package/Sources/SimCameraInjector/build.sh +2 -0
- package/Sources/SimNative/DuoHIDBridge.swift +2 -1
- package/Sources/SimNative/FrameCapture.swift +11 -3
- package/Sources/SimNative/HIDInjector.swift +1 -1
- package/Sources/SimNative/sim-module.swift +5 -0
- package/Sources/SimNativeSupport/DeviceSet.swift +34 -0
- package/Tests/SimNativeSupportTests/DeviceSetTests.swift +57 -0
- package/dist/middleware.js +49 -49
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +83 -82
- package/dist/simcam/libSimCameraInjector.dylib +0 -0
- package/package.json +1 -1
- package/src/device-hub-input.ts +4 -3
- package/src/device.ts +15 -3
- package/src/doctor.ts +2 -1
- package/src/duo-state.ts +4 -3
- package/src/event-log.ts +4 -2
- package/src/helper-lifecycle.ts +6 -1
- package/src/host-rpc.ts +9 -8
- package/src/middleware.ts +22 -14
- package/src/native.ts +8 -1
- package/src/simctl.ts +83 -2
- package/src/state.ts +9 -0
- package/src/ui-settings.ts +3 -2
package/README.md
CHANGED
|
@@ -148,6 +148,10 @@ Options:
|
|
|
148
148
|
Cap the H.264 stream bitrate (default scales with
|
|
149
149
|
screen size, 6-30 Mbps). Also read from
|
|
150
150
|
SERVE_SIM_MAX_BITRATE
|
|
151
|
+
--device-set <path>
|
|
152
|
+
Use this CoreSimulator device set instead of Xcode's
|
|
153
|
+
default (simctl --set). Applies to subcommands too.
|
|
154
|
+
Also read from SERVE_SIM_DEVICE_SET
|
|
151
155
|
--list [device] List running streams
|
|
152
156
|
--kill [device] Kill running stream(s)
|
|
153
157
|
|
|
@@ -175,6 +179,7 @@ serve-sim --panes devices,tools --fit # start with selected panes open and fit
|
|
|
175
179
|
serve-sim --theme dark # start the simulator in Dark Mode
|
|
176
180
|
serve-sim --allow-exec # enable `serve-sim exec` and .ipa drag-to-install
|
|
177
181
|
serve-sim --max-bitrate 8 # cap H.264 at 8 Mbps (slow links / shared hosts)
|
|
182
|
+
serve-sim --device-set ~/pool <udid> # a simulator of another device set
|
|
178
183
|
|
|
179
184
|
# Type text into the focused field
|
|
180
185
|
serve-sim type "Hello, world!"
|
|
@@ -218,6 +223,10 @@ The host shell — `POST /exec`, `{command}` frames on the `/exec-ws` control ch
|
|
|
218
223
|
|
|
219
224
|
`--detach` and `--no-preview` reuse a running server only when it was started with the same `--auth-token` (or with none on both sides) and the same `--allow-exec` setting (on or off on both sides); otherwise they exit with an error — stop the old server with `serve-sim -k <udid>` first.
|
|
220
225
|
|
|
226
|
+
### Device sets (`--device-set`)
|
|
227
|
+
|
|
228
|
+
Tools that keep simulators in a CoreSimulator device set of their own (device pools, CI runners) put them where `simctl list` and Simulator.app never look. `--device-set <path>` (or `SERVE_SIM_DEVICE_SET`, an existing directory) points serve-sim at that set: every `simctl` call gets `--set <path>`, each preview records its set in its state file so previews in different sets never take each other's simulators for shut down, the native capture, input and accessibility bridges look the device up in that set, and per-simulator data (permissions) is read from it. Simulator.app is not opened for such a set, since it only shows Xcode's default one. The flag applies to subcommands such as `serve-sim camera` too (each `camera` subcommand refuses a simulator the set does not list), and a command finds only previews of its own set, whether or not it names a simulator; `-l` and `-k` without a device still list and stop previews in every set. The toolbar's library shell commands (`homeButtonCommand`, the screenshot button) still name no device set; the preview page uses host actions instead.
|
|
229
|
+
|
|
221
230
|
### Camera
|
|
222
231
|
|
|
223
232
|
`serve-sim camera <bundle-id>` replaces the simulator's camera feed for a single app. A small host-side helper writes BGRA frames into a POSIX shared-memory region; an injected dylib (`DYLD_INSERT_LIBRARIES`) swizzles AVFoundation inside the simulator process so the app reads from that region instead of the simulator's stub camera.
|
|
@@ -230,6 +239,8 @@ Sources:
|
|
|
230
239
|
- **file** — image (PNG/JPEG/HEIC/…) or video (mp4/mov/m4v/webm/…). The CLI sniffs the kind from the extension and falls back to magic bytes for files without an extension.
|
|
231
240
|
- **webcam** — live `AVCaptureDevice` (built-in, Continuity, external).
|
|
232
241
|
|
|
242
|
+
Movies an app records from the injected camera (`AVCaptureMovieFileOutput`, as in expo-camera's `recordAsync` and VisionCamera) are video-only: they have no audio track, even with `recordAsync({ mute: false })`.
|
|
243
|
+
|
|
233
244
|
### Screen recording
|
|
234
245
|
|
|
235
246
|
The record button in the preview toolbar and `serve-sim record` both drive a native recorder in the server: it encodes the simulator framebuffer directly (full native resolution, constant frame rate, hardware H.264/HEVC), independent of the stream codec the viewer uses. Stopping streams the finished MP4 to the client that stopped it (a browser download, or the `-o` file) and deletes the server's temp copy. One recording runs per device; any client may stop it, and a `serve-sim record` whose recording is stopped elsewhere exits with an error instead of saving.
|
|
@@ -141,6 +141,7 @@ void SimCamLogSwallowedRuntimeError(NSString *via, id object, NSDictionary *user
|
|
|
141
141
|
}
|
|
142
142
|
- (BOOL)isHighestPhotoQualitySupported { return YES; }
|
|
143
143
|
- (BOOL)isVideoBinned { return NO; }
|
|
144
|
+
- (BOOL)isVideoHDRSupported { return NO; }
|
|
144
145
|
- (BOOL)isVideoStabilizationModeSupported:(AVCaptureVideoStabilizationMode)m { return NO; }
|
|
145
146
|
- (CGFloat)videoMaxZoomFactor { return 16.0; }
|
|
146
147
|
- (CGFloat)videoZoomFactorUpscaleThreshold { return 1.0; }
|
|
@@ -199,9 +200,20 @@ static char kFakePositionKey;
|
|
|
199
200
|
- (BOOL)isSuspended { return NO; }
|
|
200
201
|
- (BOOL)lockForConfiguration:(NSError **)e { return YES; }
|
|
201
202
|
- (void)unlockForConfiguration { }
|
|
203
|
+
// AVFoundation's setters validate against private format state the fakes
|
|
204
|
+
// lack and throw, so configuration a library applies (VisionCamera 5 sets
|
|
205
|
+
// format, frame rate and color space on every session) is accepted and
|
|
206
|
+
// dropped: the injected frames have one size and rate.
|
|
202
207
|
- (AVCaptureDeviceFormat *)activeFormat { return SimCamSharedFakeFormat(); }
|
|
208
|
+
- (void)setActiveFormat:(AVCaptureDeviceFormat *)f { (void)f; }
|
|
203
209
|
- (CMTime)activeVideoMinFrameDuration { return CMTimeMake(1, 30); }
|
|
210
|
+
- (void)setActiveVideoMinFrameDuration:(CMTime)d { (void)d; }
|
|
204
211
|
- (CMTime)activeVideoMaxFrameDuration { return CMTimeMake(1, 30); }
|
|
212
|
+
- (void)setActiveVideoMaxFrameDuration:(CMTime)d { (void)d; }
|
|
213
|
+
- (AVCaptureColorSpace)activeColorSpace { return AVCaptureColorSpace_sRGB; }
|
|
214
|
+
- (void)setActiveColorSpace:(AVCaptureColorSpace)c { (void)c; }
|
|
215
|
+
- (AVCaptureDeviceFormat *)activeDepthDataFormat { return nil; }
|
|
216
|
+
- (void)setActiveDepthDataFormat:(AVCaptureDeviceFormat *)f { (void)f; }
|
|
205
217
|
- (CGFloat)videoZoomFactor { return 1.0; }
|
|
206
218
|
- (void)setVideoZoomFactor:(CGFloat)v { (void)v; }
|
|
207
219
|
- (void)rampToVideoZoomFactor:(CGFloat)f withRate:(float)r { (void)f; (void)r; }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#import <AVFoundation/AVFoundation.h>
|
|
4
|
+
|
|
5
|
+
void SimCamInstallMovieOutputSwizzles(void);
|
|
6
|
+
|
|
7
|
+
// End the output's fake recording, if it has one. AVFoundation's error when
|
|
8
|
+
// the session's media sources or destinations change
|
|
9
|
+
// (AVErrorSessionConfigurationChanged, in Apple's documentation: "Recording
|
|
10
|
+
// stopped because the configuration of media sources and destinations
|
|
11
|
+
// changed") is for the output leaving its fake session: removed, or the
|
|
12
|
+
// session's last fake camera removed, after which stopRecording no longer
|
|
13
|
+
// reaches the fake. A session stopped or let go mid-recording has no error
|
|
14
|
+
// code of its own (AVErrorSessionNotRunning is for a recording that cannot
|
|
15
|
+
// start), so its recordings end as a stop.
|
|
16
|
+
void SimCamFakeMovieOutputLeftSession(AVCaptureOutput *output);
|
|
17
|
+
void SimCamFakeMovieOutputSessionStopped(AVCaptureOutput *output);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#import "SimCamMovieOutput.h"
|
|
2
|
+
#import "SimCamFakes.h"
|
|
3
|
+
#import "SimCamFrameSource.h"
|
|
4
|
+
#import "SimCamMovieRecorder.h"
|
|
5
|
+
#import "SimCamSwizzles.h"
|
|
6
|
+
|
|
7
|
+
#import <AVFoundation/AVFoundation.h>
|
|
8
|
+
#import <objc/runtime.h>
|
|
9
|
+
|
|
10
|
+
// What a movie output on a fake session records with in place of its own
|
|
11
|
+
// recorder, which has no media there.
|
|
12
|
+
@interface SimCamFakeMovieOutput : NSObject
|
|
13
|
+
@property (atomic, copy) NSDictionary<NSString *, id> *videoSettings;
|
|
14
|
+
@property (atomic, strong) SimCamMovieRecorder *recorder;
|
|
15
|
+
@end
|
|
16
|
+
@implementation SimCamFakeMovieOutput
|
|
17
|
+
@end
|
|
18
|
+
|
|
19
|
+
static char kSimCamFakeMovieOutputKey;
|
|
20
|
+
|
|
21
|
+
// nil for an output AVFoundation records itself.
|
|
22
|
+
static SimCamFakeMovieOutput *SimCamFakeMovieOutputFor(AVCaptureMovieFileOutput *output) {
|
|
23
|
+
if (!SimCamOutputAttachedToFakeSession(output)) return nil;
|
|
24
|
+
SimCamFakeMovieOutput *fake = objc_getAssociatedObject(output, &kSimCamFakeMovieOutputKey);
|
|
25
|
+
if (fake) return fake;
|
|
26
|
+
@synchronized (output) {
|
|
27
|
+
fake = objc_getAssociatedObject(output, &kSimCamFakeMovieOutputKey);
|
|
28
|
+
if (!fake) {
|
|
29
|
+
fake = [SimCamFakeMovieOutput new];
|
|
30
|
+
fake.videoSettings = @{ AVVideoCodecKey: AVVideoCodecTypeH264 };
|
|
31
|
+
objc_setAssociatedObject(output, &kSimCamFakeMovieOutputKey, fake, OBJC_ASSOCIATION_RETAIN);
|
|
32
|
+
}
|
|
33
|
+
return fake;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The fake that answers the output's recording getters: the output's own
|
|
38
|
+
// while it is on a fake session, and after it leaves, the one holding its
|
|
39
|
+
// last fake recording, because a removed output's recording finishes, and
|
|
40
|
+
// its delegate reads these getters, after the removal. No input reaches a
|
|
41
|
+
// native session in this process, so the output never records natively and
|
|
42
|
+
// that recording answers for good, as it does while the output is attached.
|
|
43
|
+
static SimCamFakeMovieOutput *SimCamFakeRecordingFor(AVCaptureMovieFileOutput *output) {
|
|
44
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(output);
|
|
45
|
+
if (fake) return fake;
|
|
46
|
+
fake = objc_getAssociatedObject(output, &kSimCamFakeMovieOutputKey);
|
|
47
|
+
return fake.recorder ? fake : nil;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
void SimCamFakeMovieOutputLeftSession(AVCaptureOutput *output) {
|
|
51
|
+
SimCamFakeMovieOutput *fake = objc_getAssociatedObject(output, &kSimCamFakeMovieOutputKey);
|
|
52
|
+
// The file is finished and plays, as AVFoundation's is.
|
|
53
|
+
[fake.recorder stopWithError:[NSError errorWithDomain:AVFoundationErrorDomain
|
|
54
|
+
code:AVErrorSessionConfigurationChanged
|
|
55
|
+
userInfo:@{ AVErrorRecordingSuccessfullyFinishedKey: @YES }]];
|
|
56
|
+
}
|
|
57
|
+
void SimCamFakeMovieOutputSessionStopped(AVCaptureOutput *output) {
|
|
58
|
+
SimCamFakeMovieOutput *fake = objc_getAssociatedObject(output, &kSimCamFakeMovieOutputKey);
|
|
59
|
+
[fake.recorder stop];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// AVFoundation answers these from the native connection graph, which a
|
|
63
|
+
// session with a fake camera never builds, and throws for a connection it
|
|
64
|
+
// does not own ("Invalid connection passed"). The recording methods are
|
|
65
|
+
// swizzled on AVCaptureMovieFileOutput, which overrides some of them, so
|
|
66
|
+
// AVCaptureFileOutput and its other subclasses are untouched.
|
|
67
|
+
@interface AVCaptureMovieFileOutput (SimCam)
|
|
68
|
+
@end
|
|
69
|
+
@implementation AVCaptureMovieFileOutput (SimCam)
|
|
70
|
+
- (NSArray<AVVideoCodecType> *)simcam_availableVideoCodecTypes {
|
|
71
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
72
|
+
if (!fake) return [self simcam_availableVideoCodecTypes];
|
|
73
|
+
return @[ AVVideoCodecTypeH264, AVVideoCodecTypeHEVC ];
|
|
74
|
+
}
|
|
75
|
+
- (NSArray<NSString *> *)simcam_supportedOutputSettingsKeysForConnection:(AVCaptureConnection *)connection {
|
|
76
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
77
|
+
if (!fake) return [self simcam_supportedOutputSettingsKeysForConnection:connection];
|
|
78
|
+
return @[ AVVideoCodecKey, AVVideoCompressionPropertiesKey ];
|
|
79
|
+
}
|
|
80
|
+
- (NSDictionary<NSString *, id> *)simcam_outputSettingsForConnection:(AVCaptureConnection *)connection {
|
|
81
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
82
|
+
if (!fake) return [self simcam_outputSettingsForConnection:connection];
|
|
83
|
+
if (![SimCamConnectionsForOutput(self) containsObject:connection]) {
|
|
84
|
+
[NSException raise:NSInvalidArgumentException
|
|
85
|
+
format:@"*** -[AVCaptureMovieFileOutput outputSettingsForConnection:] Invalid connection passed"];
|
|
86
|
+
}
|
|
87
|
+
return SimCamConnectionCarries(connection, AVMediaTypeVideo) ? fake.videoSettings : @{};
|
|
88
|
+
}
|
|
89
|
+
- (void)simcam_setOutputSettings:(NSDictionary<NSString *, id> *)settings
|
|
90
|
+
forConnection:(AVCaptureConnection *)connection {
|
|
91
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
92
|
+
if (!fake) {
|
|
93
|
+
[self simcam_setOutputSettings:settings forConnection:connection];
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// AVFoundation ignores settings for another output's connection, and the
|
|
97
|
+
// fake movie has no audio track, so only the output's own video
|
|
98
|
+
// connection sets them.
|
|
99
|
+
if (![SimCamConnectionsForOutput(self) containsObject:connection]) return;
|
|
100
|
+
if (!SimCamConnectionCarries(connection, AVMediaTypeVideo)) return;
|
|
101
|
+
fake.videoSettings = settings ?: @{ AVVideoCodecKey: AVVideoCodecTypeH264 };
|
|
102
|
+
}
|
|
103
|
+
- (void)simcam_startRecordingToOutputFileURL:(NSURL *)url
|
|
104
|
+
recordingDelegate:(id<AVCaptureFileOutputRecordingDelegate>)delegate {
|
|
105
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
106
|
+
if (!fake) {
|
|
107
|
+
[self simcam_startRecordingToOutputFileURL:url recordingDelegate:delegate];
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// AVFoundation closes the file in progress before it opens the next.
|
|
111
|
+
[fake.recorder stop];
|
|
112
|
+
SimCamMovieRecorder *recorder = [[SimCamMovieRecorder alloc]
|
|
113
|
+
initWithOutput:self
|
|
114
|
+
URL:url
|
|
115
|
+
delegate:delegate
|
|
116
|
+
connections:SimCamConnectionsForOutput(self)
|
|
117
|
+
videoSettings:fake.videoSettings
|
|
118
|
+
maxDuration:self.maxRecordedDuration
|
|
119
|
+
maxFileSize:self.maxRecordedFileSize];
|
|
120
|
+
fake.recorder = recorder;
|
|
121
|
+
// AVErrorSessionNotRunning, in Apple's documentation: "The recording
|
|
122
|
+
// couldn't start because the session isn't running."
|
|
123
|
+
if (!SimCamFakeSessionForOutput(self).isRunning) {
|
|
124
|
+
[recorder stopWithError:[NSError errorWithDomain:AVFoundationErrorDomain
|
|
125
|
+
code:AVErrorSessionNotRunning
|
|
126
|
+
userInfo:nil]];
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
SimCamMarkCameraInUse();
|
|
130
|
+
[[SimCamRegistry shared] startPumpingIfNeeded];
|
|
131
|
+
[recorder start];
|
|
132
|
+
}
|
|
133
|
+
- (void)simcam_stopRecording {
|
|
134
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
135
|
+
if (!fake) {
|
|
136
|
+
[self simcam_stopRecording];
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
[fake.recorder stop];
|
|
140
|
+
}
|
|
141
|
+
- (void)simcam_pauseRecording {
|
|
142
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
143
|
+
if (!fake) {
|
|
144
|
+
[self simcam_pauseRecording];
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
[fake.recorder pause];
|
|
148
|
+
}
|
|
149
|
+
- (void)simcam_resumeRecording {
|
|
150
|
+
SimCamFakeMovieOutput *fake = SimCamFakeMovieOutputFor(self);
|
|
151
|
+
if (!fake) {
|
|
152
|
+
[self simcam_resumeRecording];
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
[fake.recorder resume];
|
|
156
|
+
}
|
|
157
|
+
- (BOOL)simcam_isRecording {
|
|
158
|
+
SimCamFakeMovieOutput *fake = SimCamFakeRecordingFor(self);
|
|
159
|
+
if (!fake) return [self simcam_isRecording];
|
|
160
|
+
return fake.recorder.isRecording;
|
|
161
|
+
}
|
|
162
|
+
- (BOOL)simcam_isRecordingPaused {
|
|
163
|
+
SimCamFakeMovieOutput *fake = SimCamFakeRecordingFor(self);
|
|
164
|
+
if (!fake) return [self simcam_isRecordingPaused];
|
|
165
|
+
return fake.recorder.isPaused;
|
|
166
|
+
}
|
|
167
|
+
- (NSURL *)simcam_outputFileURL {
|
|
168
|
+
SimCamFakeMovieOutput *fake = SimCamFakeRecordingFor(self);
|
|
169
|
+
if (!fake) return [self simcam_outputFileURL];
|
|
170
|
+
return fake.recorder.isRecording ? fake.recorder.URL : nil;
|
|
171
|
+
}
|
|
172
|
+
- (CMTime)simcam_recordedDuration {
|
|
173
|
+
SimCamFakeMovieOutput *fake = SimCamFakeRecordingFor(self);
|
|
174
|
+
if (!fake) return [self simcam_recordedDuration];
|
|
175
|
+
return fake.recorder ? fake.recorder.recordedDuration : kCMTimeZero;
|
|
176
|
+
}
|
|
177
|
+
- (int64_t)simcam_recordedFileSize {
|
|
178
|
+
SimCamFakeMovieOutput *fake = SimCamFakeRecordingFor(self);
|
|
179
|
+
if (!fake) return [self simcam_recordedFileSize];
|
|
180
|
+
return fake.recorder.recordedFileSize;
|
|
181
|
+
}
|
|
182
|
+
@end
|
|
183
|
+
|
|
184
|
+
void SimCamInstallMovieOutputSwizzles(void) {
|
|
185
|
+
Class movieOut = [AVCaptureMovieFileOutput class];
|
|
186
|
+
SwizzleInstanceMethod(movieOut,
|
|
187
|
+
@selector(availableVideoCodecTypes),
|
|
188
|
+
@selector(simcam_availableVideoCodecTypes));
|
|
189
|
+
SwizzleInstanceMethod(movieOut,
|
|
190
|
+
@selector(supportedOutputSettingsKeysForConnection:),
|
|
191
|
+
@selector(simcam_supportedOutputSettingsKeysForConnection:));
|
|
192
|
+
SwizzleInstanceMethod(movieOut,
|
|
193
|
+
@selector(outputSettingsForConnection:),
|
|
194
|
+
@selector(simcam_outputSettingsForConnection:));
|
|
195
|
+
SwizzleInstanceMethod(movieOut,
|
|
196
|
+
@selector(setOutputSettings:forConnection:),
|
|
197
|
+
@selector(simcam_setOutputSettings:forConnection:));
|
|
198
|
+
SwizzleInstanceMethod(movieOut,
|
|
199
|
+
@selector(startRecordingToOutputFileURL:recordingDelegate:),
|
|
200
|
+
@selector(simcam_startRecordingToOutputFileURL:recordingDelegate:));
|
|
201
|
+
SwizzleInstanceMethod(movieOut, @selector(stopRecording), @selector(simcam_stopRecording));
|
|
202
|
+
SwizzleInstanceMethod(movieOut, @selector(isRecording), @selector(simcam_isRecording));
|
|
203
|
+
SwizzleInstanceMethod(movieOut, @selector(outputFileURL), @selector(simcam_outputFileURL));
|
|
204
|
+
SwizzleInstanceMethod(movieOut, @selector(recordedDuration), @selector(simcam_recordedDuration));
|
|
205
|
+
SwizzleInstanceMethod(movieOut, @selector(recordedFileSize), @selector(simcam_recordedFileSize));
|
|
206
|
+
if (@available(iOS 18.0, *)) {
|
|
207
|
+
SwizzleInstanceMethod(movieOut, @selector(pauseRecording), @selector(simcam_pauseRecording));
|
|
208
|
+
SwizzleInstanceMethod(movieOut, @selector(resumeRecording), @selector(simcam_resumeRecording));
|
|
209
|
+
SwizzleInstanceMethod(movieOut, @selector(isRecordingPaused), @selector(simcam_isRecordingPaused));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#import <AVFoundation/AVFoundation.h>
|
|
4
|
+
|
|
5
|
+
// One AVCaptureMovieFileOutput recording from the injected frames. A session
|
|
6
|
+
// with a fake camera never runs natively, so the movie output's own recorder
|
|
7
|
+
// has no media; this writes the current frame at the frame rate to the URL
|
|
8
|
+
// with AVAssetWriter and calls the recording delegate as AVFoundation does.
|
|
9
|
+
@interface SimCamMovieRecorder : NSObject
|
|
10
|
+
- (instancetype)initWithOutput:(AVCaptureFileOutput *)output
|
|
11
|
+
URL:(NSURL *)url
|
|
12
|
+
delegate:(id<AVCaptureFileOutputRecordingDelegate>)delegate
|
|
13
|
+
connections:(NSArray<AVCaptureConnection *> *)connections
|
|
14
|
+
videoSettings:(NSDictionary<NSString *, id> *)videoSettings
|
|
15
|
+
maxDuration:(CMTime)maxDuration
|
|
16
|
+
maxFileSize:(int64_t)maxFileSize;
|
|
17
|
+
- (void)start;
|
|
18
|
+
- (void)stop;
|
|
19
|
+
// Ends the recording as `stop` does, with `error` for the delegate once the
|
|
20
|
+
// file is finished. Before `start`, the recording fails with `error` and
|
|
21
|
+
// writes nothing.
|
|
22
|
+
- (void)stopWithError:(NSError *)error;
|
|
23
|
+
- (void)pause;
|
|
24
|
+
- (void)resume;
|
|
25
|
+
@property (nonatomic, readonly) NSURL *URL;
|
|
26
|
+
@property (nonatomic, readonly, getter=isRecording) BOOL recording;
|
|
27
|
+
@property (nonatomic, readonly, getter=isPaused) BOOL paused;
|
|
28
|
+
@property (nonatomic, readonly) CMTime recordedDuration;
|
|
29
|
+
@property (nonatomic, readonly) int64_t recordedFileSize;
|
|
30
|
+
@end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#import "SimCamMovieRecorder.h"
|
|
2
|
+
#import "SimCamFrameSource.h"
|
|
3
|
+
#import "SimCamLog.h"
|
|
4
|
+
|
|
5
|
+
#import <QuartzCore/QuartzCore.h>
|
|
6
|
+
#import <objc/message.h>
|
|
7
|
+
#include <stdatomic.h>
|
|
8
|
+
|
|
9
|
+
static const int32_t kSimCamMovieFrameRate = 30;
|
|
10
|
+
static const int32_t kSimCamMovieTimescale = 600;
|
|
11
|
+
|
|
12
|
+
// AVFoundation reports a failed recording with
|
|
13
|
+
// AVErrorRecordingSuccessfullyFinishedKey set to NO, which apps read to tell
|
|
14
|
+
// a usable file from a broken one.
|
|
15
|
+
static NSError *SimCamRecordingFailure(NSError *cause) {
|
|
16
|
+
NSMutableDictionary *userInfo = [cause.userInfo mutableCopy] ?: [NSMutableDictionary new];
|
|
17
|
+
userInfo[AVErrorRecordingSuccessfullyFinishedKey] = @NO;
|
|
18
|
+
return [NSError errorWithDomain:cause.domain ?: AVFoundationErrorDomain
|
|
19
|
+
code:cause ? cause.code : AVErrorUnknown
|
|
20
|
+
userInfo:userInfo];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A limit that ends the recording leaves a finished file that plays, which
|
|
24
|
+
// AVFoundation reports with AVErrorRecordingSuccessfullyFinishedKey set to YES.
|
|
25
|
+
static NSError *SimCamRecordingLimitReached(AVError code) {
|
|
26
|
+
return [NSError errorWithDomain:AVFoundationErrorDomain
|
|
27
|
+
code:code
|
|
28
|
+
userInfo:@{ AVErrorRecordingSuccessfullyFinishedKey: @YES }];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@implementation SimCamMovieRecorder {
|
|
32
|
+
// Held until the delegate hears the recording finished, as AVFoundation
|
|
33
|
+
// always tells it, even when the app lets the output go first.
|
|
34
|
+
AVCaptureFileOutput *_output;
|
|
35
|
+
id<AVCaptureFileOutputRecordingDelegate> _delegate;
|
|
36
|
+
NSArray<AVCaptureConnection *> *_connections;
|
|
37
|
+
NSDictionary<NSString *, id> *_videoSettings;
|
|
38
|
+
CMTime _maxDuration;
|
|
39
|
+
// Bytes; 0 is no limit.
|
|
40
|
+
int64_t _maxFileSize;
|
|
41
|
+
dispatch_queue_t _queue;
|
|
42
|
+
dispatch_source_t _timer;
|
|
43
|
+
AVAssetWriter *_writer;
|
|
44
|
+
AVAssetWriterInput *_input;
|
|
45
|
+
AVAssetWriterInputPixelBufferAdaptor *_adaptor;
|
|
46
|
+
CFTimeInterval _startedAt;
|
|
47
|
+
CFTimeInterval _pausedAt;
|
|
48
|
+
CFTimeInterval _pausedSeconds;
|
|
49
|
+
CMTime _lastFrameTime;
|
|
50
|
+
BOOL _finishing;
|
|
51
|
+
// Read from any thread through the public getters.
|
|
52
|
+
_Atomic(BOOL) _recording;
|
|
53
|
+
_Atomic(BOOL) _paused;
|
|
54
|
+
_Atomic(int64_t) _recordedValue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
- (instancetype)initWithOutput:(AVCaptureFileOutput *)output
|
|
58
|
+
URL:(NSURL *)url
|
|
59
|
+
delegate:(id<AVCaptureFileOutputRecordingDelegate>)delegate
|
|
60
|
+
connections:(NSArray<AVCaptureConnection *> *)connections
|
|
61
|
+
videoSettings:(NSDictionary<NSString *, id> *)videoSettings
|
|
62
|
+
maxDuration:(CMTime)maxDuration
|
|
63
|
+
maxFileSize:(int64_t)maxFileSize {
|
|
64
|
+
if ((self = [super init])) {
|
|
65
|
+
_output = output;
|
|
66
|
+
_URL = url;
|
|
67
|
+
_delegate = delegate;
|
|
68
|
+
_connections = [connections copy] ?: @[];
|
|
69
|
+
_videoSettings = [videoSettings copy] ?: @{};
|
|
70
|
+
_maxDuration = maxDuration;
|
|
71
|
+
_maxFileSize = maxFileSize;
|
|
72
|
+
_queue = dispatch_queue_create("dev.servesim.simcam.movie", DISPATCH_QUEUE_SERIAL);
|
|
73
|
+
_lastFrameTime = kCMTimeInvalid;
|
|
74
|
+
}
|
|
75
|
+
return self;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
- (BOOL)isRecording { return _recording; }
|
|
79
|
+
- (BOOL)isPaused { return _paused; }
|
|
80
|
+
- (CMTime)recordedDuration { return CMTimeMake(_recordedValue, kSimCamMovieTimescale); }
|
|
81
|
+
- (int64_t)recordedFileSize {
|
|
82
|
+
NSDictionary *attributes = [[NSFileManager defaultManager]
|
|
83
|
+
attributesOfItemAtPath:_URL.path error:nil];
|
|
84
|
+
return (int64_t)attributes.fileSize;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
- (void)start {
|
|
88
|
+
_recording = YES;
|
|
89
|
+
dispatch_async(_queue, ^{
|
|
90
|
+
NSError *error = nil;
|
|
91
|
+
if (![self openWriter:&error]) {
|
|
92
|
+
[self finishWithError:SimCamRecordingFailure(error) endingAt:kCMTimeInvalid];
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
self->_startedAt = CACurrentMediaTime();
|
|
96
|
+
[self appendFrameAt:kCMTimeZero];
|
|
97
|
+
simcam_log(@"movie recording started → %@", self->_URL.lastPathComponent);
|
|
98
|
+
[self callDelegate:@selector(captureOutput:didStartRecordingToOutputFileAtURL:fromConnections:)];
|
|
99
|
+
self->_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self->_queue);
|
|
100
|
+
uint64_t intervalNs = NSEC_PER_SEC / kSimCamMovieFrameRate;
|
|
101
|
+
dispatch_source_set_timer(self->_timer,
|
|
102
|
+
dispatch_time(DISPATCH_TIME_NOW, (int64_t)intervalNs), intervalNs, intervalNs / 10);
|
|
103
|
+
__weak SimCamMovieRecorder *weakSelf = self;
|
|
104
|
+
dispatch_source_set_event_handler(self->_timer, ^{ [weakSelf tick]; });
|
|
105
|
+
dispatch_resume(self->_timer);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
- (void)stop {
|
|
110
|
+
[self stopWithError:nil];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
- (void)stopWithError:(NSError *)error {
|
|
114
|
+
dispatch_async(_queue, ^{ [self finishWithError:error endingAt:kCMTimeInvalid]; });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
- (void)pause {
|
|
118
|
+
dispatch_async(_queue, ^{
|
|
119
|
+
if (self->_paused || self->_finishing) return;
|
|
120
|
+
self->_pausedAt = CACurrentMediaTime();
|
|
121
|
+
self->_paused = YES;
|
|
122
|
+
[self callDelegate:@selector(captureOutput:didPauseRecordingToOutputFileAtURL:fromConnections:)];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
- (void)resume {
|
|
127
|
+
dispatch_async(_queue, ^{
|
|
128
|
+
if (!self->_paused || self->_finishing) return;
|
|
129
|
+
self->_pausedSeconds += CACurrentMediaTime() - self->_pausedAt;
|
|
130
|
+
self->_paused = NO;
|
|
131
|
+
[self callDelegate:@selector(captureOutput:didResumeRecordingToOutputFileAtURL:fromConnections:)];
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#pragma mark - Queue-confined
|
|
136
|
+
|
|
137
|
+
- (BOOL)openWriter:(NSError **)error {
|
|
138
|
+
CVPixelBufferRef frame = [[SimCamRegistry shared] currentPixelBuffer];
|
|
139
|
+
size_t width = frame ? CVPixelBufferGetWidth(frame) : 1280;
|
|
140
|
+
size_t height = frame ? CVPixelBufferGetHeight(frame) : 720;
|
|
141
|
+
if (frame) CVPixelBufferRelease(frame);
|
|
142
|
+
|
|
143
|
+
if ([[NSFileManager defaultManager] fileExistsAtPath:_URL.path]) {
|
|
144
|
+
if (error) *error = [NSError errorWithDomain:AVFoundationErrorDomain
|
|
145
|
+
code:AVErrorFileAlreadyExists
|
|
146
|
+
userInfo:@{
|
|
147
|
+
NSURLErrorKey: _URL,
|
|
148
|
+
NSLocalizedDescriptionKey: @"A file already exists at the recording URL",
|
|
149
|
+
}];
|
|
150
|
+
return NO;
|
|
151
|
+
}
|
|
152
|
+
// AVCaptureMovieFileOutput writes a QuickTime movie whatever the file's
|
|
153
|
+
// extension says, so the fake does too.
|
|
154
|
+
_writer = [AVAssetWriter assetWriterWithURL:_URL fileType:AVFileTypeQuickTimeMovie error:error];
|
|
155
|
+
if (!_writer) return NO;
|
|
156
|
+
|
|
157
|
+
NSMutableDictionary *settings = [NSMutableDictionary dictionaryWithDictionary:@{
|
|
158
|
+
AVVideoCodecKey: _videoSettings[AVVideoCodecKey] ?: AVVideoCodecTypeH264,
|
|
159
|
+
AVVideoWidthKey: @(width),
|
|
160
|
+
AVVideoHeightKey: @(height),
|
|
161
|
+
}];
|
|
162
|
+
if (_videoSettings[AVVideoCompressionPropertiesKey]) {
|
|
163
|
+
settings[AVVideoCompressionPropertiesKey] = _videoSettings[AVVideoCompressionPropertiesKey];
|
|
164
|
+
}
|
|
165
|
+
_input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:settings];
|
|
166
|
+
_input.expectsMediaDataInRealTime = YES;
|
|
167
|
+
_adaptor = [AVAssetWriterInputPixelBufferAdaptor
|
|
168
|
+
assetWriterInputPixelBufferAdaptorWithAssetWriterInput:_input
|
|
169
|
+
sourcePixelBufferAttributes:nil];
|
|
170
|
+
if (![_writer canAddInput:_input]) {
|
|
171
|
+
if (error) *error = [NSError errorWithDomain:AVFoundationErrorDomain
|
|
172
|
+
code:AVErrorUnknown
|
|
173
|
+
userInfo:@{ NSLocalizedDescriptionKey: @"serve-sim could not add a video track" }];
|
|
174
|
+
return NO;
|
|
175
|
+
}
|
|
176
|
+
[_writer addInput:_input];
|
|
177
|
+
if (![_writer startWriting]) {
|
|
178
|
+
if (error) *error = _writer.error;
|
|
179
|
+
return NO;
|
|
180
|
+
}
|
|
181
|
+
[_writer startSessionAtSourceTime:kCMTimeZero];
|
|
182
|
+
return YES;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
- (void)tick {
|
|
186
|
+
if (_finishing) return;
|
|
187
|
+
if (_writer.status == AVAssetWriterStatusFailed) {
|
|
188
|
+
[self finishWithError:SimCamRecordingFailure(_writer.error) endingAt:kCMTimeInvalid];
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Checked while paused too, in case the writer flushes what it had
|
|
192
|
+
// buffered then. The writer flushes in chunks and adds the movie header
|
|
193
|
+
// as it finishes, so the finished file runs past the limit by what it
|
|
194
|
+
// had buffered.
|
|
195
|
+
if (_maxFileSize > 0 && self.recordedFileSize >= _maxFileSize) {
|
|
196
|
+
[self finishWithError:SimCamRecordingLimitReached(AVErrorMaximumFileSizeReached) endingAt:kCMTimeInvalid];
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (_paused) return;
|
|
200
|
+
CFTimeInterval elapsed = CACurrentMediaTime() - _startedAt - _pausedSeconds;
|
|
201
|
+
CMTime now = CMTimeMakeWithSeconds(elapsed, kSimCamMovieTimescale);
|
|
202
|
+
if (CMTIME_IS_VALID(_maxDuration) && CMTimeCompare(now, _maxDuration) >= 0) {
|
|
203
|
+
[self finishWithError:SimCamRecordingLimitReached(AVErrorMaximumDurationReached) endingAt:_maxDuration];
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
[self appendFrameAt:now];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
- (void)appendFrameAt:(CMTime)time {
|
|
210
|
+
if (CMTIME_IS_VALID(_lastFrameTime) && CMTimeCompare(time, _lastFrameTime) <= 0) return;
|
|
211
|
+
if (!_input.isReadyForMoreMediaData) return;
|
|
212
|
+
CVPixelBufferRef frame = [[SimCamRegistry shared] currentPixelBuffer];
|
|
213
|
+
if (!frame) return;
|
|
214
|
+
if ([_adaptor appendPixelBuffer:frame withPresentationTime:time]) {
|
|
215
|
+
_lastFrameTime = time;
|
|
216
|
+
_recordedValue = CMTimeConvertScale(time, kSimCamMovieTimescale, kCMTimeRoundingMethod_Default).value;
|
|
217
|
+
}
|
|
218
|
+
CVPixelBufferRelease(frame);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// `end` is where the movie stops; invalid means one frame past the last.
|
|
222
|
+
- (void)finishWithError:(NSError *)error endingAt:(CMTime)end {
|
|
223
|
+
if (_finishing) return;
|
|
224
|
+
_finishing = YES;
|
|
225
|
+
if (_timer) {
|
|
226
|
+
dispatch_source_cancel(_timer);
|
|
227
|
+
_timer = nil;
|
|
228
|
+
}
|
|
229
|
+
void (^deliver)(NSError *) = ^(NSError *result) {
|
|
230
|
+
self->_recording = NO;
|
|
231
|
+
self->_paused = NO;
|
|
232
|
+
simcam_log(@"movie recording finished → %@ (%.2f s, error=%@)",
|
|
233
|
+
self->_URL.lastPathComponent, CMTimeGetSeconds(self.recordedDuration),
|
|
234
|
+
result.localizedDescription ?: @"none");
|
|
235
|
+
AVCaptureFileOutput *output = self->_output;
|
|
236
|
+
[self->_delegate captureOutput:output
|
|
237
|
+
didFinishRecordingToOutputFileAtURL:self->_URL
|
|
238
|
+
fromConnections:self->_connections
|
|
239
|
+
error:result];
|
|
240
|
+
self->_delegate = nil;
|
|
241
|
+
self->_output = nil;
|
|
242
|
+
};
|
|
243
|
+
if (_writer.status != AVAssetWriterStatusWriting) {
|
|
244
|
+
deliver(SimCamRecordingFailure(error ?: _writer.error));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
[_input markAsFinished];
|
|
248
|
+
if (!CMTIME_IS_VALID(end) && CMTIME_IS_VALID(_lastFrameTime)) {
|
|
249
|
+
end = CMTimeAdd(_lastFrameTime, CMTimeMake(1, kSimCamMovieFrameRate));
|
|
250
|
+
}
|
|
251
|
+
if (CMTIME_IS_VALID(end)) {
|
|
252
|
+
[_writer endSessionAtSourceTime:end];
|
|
253
|
+
_recordedValue = CMTimeConvertScale(end, kSimCamMovieTimescale, kCMTimeRoundingMethod_Default).value;
|
|
254
|
+
}
|
|
255
|
+
[_writer finishWritingWithCompletionHandler:^{
|
|
256
|
+
dispatch_async(self->_queue, ^{
|
|
257
|
+
BOOL completed = self->_writer.status == AVAssetWriterStatusCompleted;
|
|
258
|
+
deliver(completed ? error : SimCamRecordingFailure(self->_writer.error));
|
|
259
|
+
});
|
|
260
|
+
}];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
- (void)callDelegate:(SEL)selector {
|
|
264
|
+
if (![_delegate respondsToSelector:selector]) return;
|
|
265
|
+
AVCaptureFileOutput *output = _output;
|
|
266
|
+
((void (*)(id, SEL, AVCaptureFileOutput *, NSURL *, NSArray *))objc_msgSend)(
|
|
267
|
+
_delegate, selector, output, _URL, _connections);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@end
|
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
#pragma once
|
|
2
2
|
|
|
3
|
+
#import <AVFoundation/AVFoundation.h>
|
|
4
|
+
|
|
3
5
|
void SimCamInstallSwizzles(void);
|
|
6
|
+
|
|
7
|
+
// Shared with the other swizzle files.
|
|
8
|
+
BOOL SimCamOutputAttachedToFakeSession(AVCaptureOutput *output);
|
|
9
|
+
AVCaptureSession *SimCamFakeSessionForOutput(AVCaptureOutput *output);
|
|
10
|
+
NSArray<AVCaptureConnection *> *SimCamConnectionsForOutput(AVCaptureOutput *output);
|
|
11
|
+
BOOL SimCamConnectionCarries(AVCaptureConnection *connection, AVMediaType mediaType);
|
|
12
|
+
// Hidden from the dylib's exports, as the one helper without the SimCam
|
|
13
|
+
// prefix.
|
|
14
|
+
__attribute__((visibility("hidden"))) BOOL SwizzleInstanceMethod(Class cls, SEL orig, SEL swiz);
|