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 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);