node-mac-recorder 2.24.11 → 2.24.13
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/binding.gyp +2 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/ios_device_recorder.mm +551 -79
package/binding.gyp
CHANGED
package/index.js
CHANGED
|
@@ -176,6 +176,7 @@ class MacRecorder extends EventEmitter {
|
|
|
176
176
|
model: device?.model || null,
|
|
177
177
|
connected: device?.connected !== false,
|
|
178
178
|
suspended: device?.suspended === true,
|
|
179
|
+
captureReady: device?.captureReady !== false,
|
|
179
180
|
width: Number(device?.width) || 0,
|
|
180
181
|
height: Number(device?.height) || 0,
|
|
181
182
|
hasAudio: device?.hasAudio !== false,
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
#import <AVFoundation/AVFoundation.h>
|
|
3
3
|
#import <CoreMediaIO/CMIOHardware.h>
|
|
4
4
|
#import <Foundation/Foundation.h>
|
|
5
|
+
#import <IOKit/IOKitLib.h>
|
|
5
6
|
#import "logging.h"
|
|
6
7
|
#import "sync_timeline.h"
|
|
7
8
|
|
|
@@ -27,6 +28,16 @@ extern "C" bool resumeIOSDeviceRecording(void);
|
|
|
27
28
|
@property(atomic) BOOL startRequested;
|
|
28
29
|
@property(atomic) BOOL stopRequested;
|
|
29
30
|
@property(atomic, strong) NSError *finishError;
|
|
31
|
+
@property(atomic) BOOL paused;
|
|
32
|
+
@property(atomic) BOOL segmentStartCompleted;
|
|
33
|
+
@property(atomic) BOOL segmentFinishCompleted;
|
|
34
|
+
@property(atomic, strong) NSError *segmentFinishError;
|
|
35
|
+
@property(nonatomic, strong) NSMutableArray<NSString *> *segmentPaths;
|
|
36
|
+
@property(nonatomic, copy) NSString *currentSegmentPath;
|
|
37
|
+
@property(nonatomic) NSUInteger nextSegmentIndex;
|
|
38
|
+
@property(nonatomic) BOOL segmentFailure;
|
|
39
|
+
@property(nonatomic, strong) NSMutableArray<NSDictionary<NSString *, NSNumber *> *> *pauseRanges;
|
|
40
|
+
@property(nonatomic) NSTimeInterval pauseStartedAtSeconds;
|
|
30
41
|
@property(nonatomic) BOOL captureCamera;
|
|
31
42
|
@property(nonatomic) BOOL captureMicrophone;
|
|
32
43
|
@property(nonatomic, copy) NSString *cameraOutputPath;
|
|
@@ -34,16 +45,45 @@ extern "C" bool resumeIOSDeviceRecording(void);
|
|
|
34
45
|
@property(nonatomic, strong) NSDate *primaryStartedAt;
|
|
35
46
|
@end
|
|
36
47
|
|
|
48
|
+
static BOOL MRIOSHasProducedMedia(MRIOSDeviceRecorder *recorder) {
|
|
49
|
+
if (!recorder.movieOutput.isRecording) return NO;
|
|
50
|
+
CMTime duration = recorder.movieOutput.recordedDuration;
|
|
51
|
+
return CMTIME_IS_NUMERIC(duration) &&
|
|
52
|
+
CMTIME_COMPARE_INLINE(duration, >, kCMTimeZero);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static NSError *MRIOSNoFramesError(void) {
|
|
56
|
+
return [NSError errorWithDomain:@"MacRecorderIOS"
|
|
57
|
+
code:17
|
|
58
|
+
userInfo:@{
|
|
59
|
+
NSLocalizedDescriptionKey:
|
|
60
|
+
@"iPhone is connected but is not sending video. Unlock it, keep the screen awake, then try again."
|
|
61
|
+
}];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static void MRIOSMarkSegmentStarted(MRIOSDeviceRecorder *recorder,
|
|
65
|
+
BOOL confirmedByDelegate) {
|
|
66
|
+
recorder.recording = YES;
|
|
67
|
+
recorder.segmentStartCompleted = YES;
|
|
68
|
+
if (!recorder.startCompleted) {
|
|
69
|
+
recorder.startCompleted = YES;
|
|
70
|
+
recorder.primaryStartedAt = [NSDate date];
|
|
71
|
+
if (!recorder.stopRequested) {
|
|
72
|
+
MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!confirmedByDelegate) {
|
|
76
|
+
MRLog(@"✅ iPhone capture start confirmed from recorded media progress");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
@implementation MRIOSDeviceRecorder
|
|
38
81
|
|
|
39
82
|
- (void)captureOutput:(AVCaptureFileOutput *)captureOutput
|
|
40
83
|
didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
|
|
41
84
|
fromConnections:(NSArray<AVCaptureConnection *> *)connections {
|
|
42
|
-
self
|
|
43
|
-
|
|
44
|
-
self.primaryStartedAt = [NSDate date];
|
|
45
|
-
if (!self.stopRequested) MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
|
|
46
|
-
MRLog(@"📱 iPhone capture started: %@", fileURL.path);
|
|
85
|
+
MRIOSMarkSegmentStarted(self, YES);
|
|
86
|
+
MRLog(@"📱 iPhone capture segment started: %@", fileURL.path);
|
|
47
87
|
}
|
|
48
88
|
|
|
49
89
|
- (void)captureOutput:(AVCaptureFileOutput *)captureOutput
|
|
@@ -51,16 +91,26 @@ extern "C" bool resumeIOSDeviceRecording(void);
|
|
|
51
91
|
fromConnections:(NSArray<AVCaptureConnection *> *)connections
|
|
52
92
|
error:(NSError *)error {
|
|
53
93
|
self.recording = NO;
|
|
54
|
-
self.
|
|
55
|
-
self.
|
|
94
|
+
self.segmentFinishError = error;
|
|
95
|
+
self.segmentFinishCompleted = YES;
|
|
96
|
+
if (self.stopRequested) {
|
|
97
|
+
self.finishError = error;
|
|
98
|
+
self.finishCompleted = YES;
|
|
99
|
+
}
|
|
56
100
|
if (error) {
|
|
57
101
|
NSNumber *successfullyFinished = error.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
|
|
58
102
|
if (![successfullyFinished boolValue]) {
|
|
59
|
-
|
|
103
|
+
self.segmentFailure = YES;
|
|
104
|
+
MRLog(@"❌ iPhone capture segment finalize failed: %@ (domain=%@, code=%ld, reason=%@, userInfo=%@)",
|
|
105
|
+
error.localizedDescription,
|
|
106
|
+
error.domain,
|
|
107
|
+
(long)error.code,
|
|
108
|
+
error.localizedFailureReason ?: @"",
|
|
109
|
+
error.userInfo ?: @{});
|
|
60
110
|
return;
|
|
61
111
|
}
|
|
62
112
|
}
|
|
63
|
-
MRLog(@"✅ iPhone capture finalized: %@", outputFileURL.path);
|
|
113
|
+
MRLog(@"✅ iPhone capture segment finalized: %@", outputFileURL.path);
|
|
64
114
|
}
|
|
65
115
|
|
|
66
116
|
@end
|
|
@@ -68,28 +118,31 @@ extern "C" bool resumeIOSDeviceRecording(void);
|
|
|
68
118
|
static MRIOSDeviceRecorder *g_iosRecorder = nil;
|
|
69
119
|
|
|
70
120
|
static void MREnableIOSScreenCaptureDevices(void) {
|
|
71
|
-
|
|
72
|
-
dispatch_once
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
121
|
+
// Reapply this process-level opt-in before every discovery. CoreMediaIO can
|
|
122
|
+
// restart after a cable/session transition; dispatch_once would then leave
|
|
123
|
+
// the replacement service without the flag and later scans would be empty.
|
|
124
|
+
CMIOObjectPropertyAddress address = {
|
|
125
|
+
kCMIOHardwarePropertyAllowScreenCaptureDevices,
|
|
126
|
+
kCMIOObjectPropertyScopeGlobal,
|
|
127
|
+
kCMIOObjectPropertyElementMain
|
|
128
|
+
};
|
|
129
|
+
UInt32 allow = 1;
|
|
130
|
+
OSStatus status = CMIOObjectSetPropertyData(
|
|
131
|
+
kCMIOObjectSystemObject,
|
|
132
|
+
&address,
|
|
133
|
+
0,
|
|
134
|
+
NULL,
|
|
135
|
+
sizeof(allow),
|
|
136
|
+
&allow
|
|
137
|
+
);
|
|
138
|
+
if (status == noErr) {
|
|
139
|
+
static dispatch_once_t successLogToken;
|
|
140
|
+
dispatch_once(&successLogToken, ^{
|
|
88
141
|
MRLog(@"✅ CoreMediaIO iPhone screen capture devices enabled");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
MRLog(@"❌ CoreMediaIO could not enable iPhone screen capture devices (OSStatus=%d)", (int)status);
|
|
145
|
+
}
|
|
93
146
|
}
|
|
94
147
|
|
|
95
148
|
static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
|
|
@@ -126,17 +179,94 @@ static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
|
|
|
126
179
|
return result;
|
|
127
180
|
}
|
|
128
181
|
|
|
129
|
-
static
|
|
182
|
+
static NSString *MRUSBStringProperty(io_service_t service, CFStringRef key) {
|
|
183
|
+
CFTypeRef value = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0);
|
|
184
|
+
if (!value) return nil;
|
|
185
|
+
NSString *result = nil;
|
|
186
|
+
if (CFGetTypeID(value) == CFStringGetTypeID()) {
|
|
187
|
+
result = [NSString stringWithString:(NSString *)value];
|
|
188
|
+
}
|
|
189
|
+
CFRelease(value);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
static NSNumber *MRUSBNumberProperty(io_service_t service, CFStringRef key) {
|
|
194
|
+
CFTypeRef value = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0);
|
|
195
|
+
if (!value) return nil;
|
|
196
|
+
NSNumber *result = nil;
|
|
197
|
+
if (CFGetTypeID(value) == CFNumberGetTypeID() ||
|
|
198
|
+
CFGetTypeID(value) == CFBooleanGetTypeID()) {
|
|
199
|
+
result = [NSNumber numberWithLongLong:[(NSNumber *)value longLongValue]];
|
|
200
|
+
}
|
|
201
|
+
CFRelease(value);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
static NSArray<NSDictionary *> *MRUSBConnectedIOSDevices(void) {
|
|
206
|
+
NSMutableArray<NSDictionary *> *devices = [NSMutableArray array];
|
|
207
|
+
CFMutableDictionaryRef matching = IOServiceMatching("IOUSBHostDevice");
|
|
208
|
+
if (!matching) return devices;
|
|
209
|
+
|
|
210
|
+
io_iterator_t iterator = IO_OBJECT_NULL;
|
|
211
|
+
#pragma clang diagnostic push
|
|
212
|
+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
|
213
|
+
kern_return_t status = IOServiceGetMatchingServices(kIOMasterPortDefault, matching, &iterator);
|
|
214
|
+
#pragma clang diagnostic pop
|
|
215
|
+
if (status != KERN_SUCCESS || iterator == IO_OBJECT_NULL) return devices;
|
|
216
|
+
|
|
217
|
+
io_service_t service = IO_OBJECT_NULL;
|
|
218
|
+
while ((service = IOIteratorNext(iterator))) {
|
|
219
|
+
NSNumber *supportsIOS = MRUSBNumberProperty(service, CFSTR("SupportsIPhoneOS"));
|
|
220
|
+
NSNumber *vendorId = MRUSBNumberProperty(service, CFSTR("idVendor"));
|
|
221
|
+
if (supportsIOS.boolValue && vendorId.unsignedIntegerValue == 0x05ac) {
|
|
222
|
+
NSString *name = MRUSBStringProperty(service, CFSTR("USB Product Name"));
|
|
223
|
+
NSString *serial = MRUSBStringProperty(service, CFSTR("USB Serial Number"));
|
|
224
|
+
if (serial.length == 0) {
|
|
225
|
+
serial = MRUSBStringProperty(service, CFSTR("kUSBSerialNumberString"));
|
|
226
|
+
}
|
|
227
|
+
NSNumber *location = MRUSBNumberProperty(service, CFSTR("locationID"));
|
|
228
|
+
NSString *identifier = serial.length > 0
|
|
229
|
+
? [NSString stringWithFormat:@"usb:%@", serial]
|
|
230
|
+
: [NSString stringWithFormat:@"usb:%llu", location.unsignedLongLongValue];
|
|
231
|
+
[devices addObject:@{
|
|
232
|
+
@"id": identifier,
|
|
233
|
+
@"name": name.length > 0 ? name : @"iPhone",
|
|
234
|
+
@"manufacturer": @"Apple",
|
|
235
|
+
@"model": @"",
|
|
236
|
+
@"connected": @YES,
|
|
237
|
+
@"suspended": @YES,
|
|
238
|
+
@"captureReady": @NO,
|
|
239
|
+
@"width": @0,
|
|
240
|
+
@"height": @0,
|
|
241
|
+
@"hasAudio": @YES,
|
|
242
|
+
@"transport": @"usb"
|
|
243
|
+
}];
|
|
244
|
+
}
|
|
245
|
+
IOObjectRelease(service);
|
|
246
|
+
}
|
|
247
|
+
IOObjectRelease(iterator);
|
|
248
|
+
return devices;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
static NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(NSTimeInterval timeoutSeconds) {
|
|
130
252
|
MREnableIOSScreenCaptureDevices();
|
|
131
253
|
|
|
132
|
-
// CoreMediaIO publishes the USB screen device asynchronously
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:
|
|
254
|
+
// CoreMediaIO publishes and resumes the USB screen device asynchronously.
|
|
255
|
+
// Recording startup may wait briefly for that transition. Inventory scans
|
|
256
|
+
// request an immediate snapshot so renderer monitoring never blocks the UI.
|
|
257
|
+
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:MAX(0.0, timeoutSeconds)];
|
|
136
258
|
NSArray<AVCaptureDevice *> *devices = nil;
|
|
137
259
|
do {
|
|
138
260
|
devices = MRDiscoverIOSCaptureDevices();
|
|
139
|
-
|
|
261
|
+
BOOL hasActiveDevice = NO;
|
|
262
|
+
for (AVCaptureDevice *device in devices) {
|
|
263
|
+
if (device.isConnected && !device.isSuspended) {
|
|
264
|
+
hasActiveDevice = YES;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (hasActiveDevice) break;
|
|
269
|
+
if ([deadline timeIntervalSinceNow] <= 0) break;
|
|
140
270
|
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
|
|
141
271
|
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
|
|
142
272
|
} while ([deadline timeIntervalSinceNow] > 0);
|
|
@@ -144,10 +274,16 @@ static NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(void) {
|
|
|
144
274
|
}
|
|
145
275
|
|
|
146
276
|
static AVCaptureDevice *MRIOSDeviceForId(NSString *deviceId) {
|
|
147
|
-
NSArray<AVCaptureDevice *> *devices = MRIOSCaptureDevices();
|
|
148
|
-
if (deviceId.length == 0)
|
|
277
|
+
NSArray<AVCaptureDevice *> *devices = MRIOSCaptureDevices(3.0);
|
|
278
|
+
if (deviceId.length == 0) {
|
|
279
|
+
for (AVCaptureDevice *device in devices) {
|
|
280
|
+
if (device.isConnected && !device.isSuspended) return device;
|
|
281
|
+
}
|
|
282
|
+
return nil;
|
|
283
|
+
}
|
|
149
284
|
for (AVCaptureDevice *device in devices) {
|
|
150
|
-
if ([device.uniqueID isEqualToString:deviceId]
|
|
285
|
+
if ([device.uniqueID isEqualToString:deviceId] &&
|
|
286
|
+
device.isConnected && !device.isSuspended) return device;
|
|
151
287
|
}
|
|
152
288
|
return nil;
|
|
153
289
|
}
|
|
@@ -161,9 +297,295 @@ static bool MRWaitForFlag(bool (^readFlag)(void), NSTimeInterval timeoutSeconds)
|
|
|
161
297
|
return readFlag();
|
|
162
298
|
}
|
|
163
299
|
|
|
300
|
+
static BOOL MRIOSFileOutputSucceeded(NSError *error) {
|
|
301
|
+
if (!error) return YES;
|
|
302
|
+
NSNumber *successfullyFinished = error.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
|
|
303
|
+
return [successfullyFinished boolValue];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
static NSTimeInterval MRIOSRecordedDurationSeconds(MRIOSDeviceRecorder *recorder) {
|
|
307
|
+
CMTime duration = recorder.movieOutput.recordedDuration;
|
|
308
|
+
if (CMTIME_IS_NUMERIC(duration) &&
|
|
309
|
+
CMTIME_COMPARE_INLINE(duration, >=, kCMTimeZero)) {
|
|
310
|
+
return MAX(0.0, CMTimeGetSeconds(duration));
|
|
311
|
+
}
|
|
312
|
+
if (recorder.primaryStartedAt) {
|
|
313
|
+
return MAX(0.0, -[recorder.primaryStartedAt timeIntervalSinceNow]);
|
|
314
|
+
}
|
|
315
|
+
return 0.0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
static void MRIOSBeginPauseRange(MRIOSDeviceRecorder *recorder) {
|
|
319
|
+
recorder.pauseStartedAtSeconds = MRIOSRecordedDurationSeconds(recorder);
|
|
320
|
+
MRLog(@"⏸️ iPhone capture pause marker at %.3f seconds",
|
|
321
|
+
recorder.pauseStartedAtSeconds);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
static void MRIOSEndPauseRange(MRIOSDeviceRecorder *recorder) {
|
|
325
|
+
if (recorder.pauseStartedAtSeconds < 0.0) return;
|
|
326
|
+
NSTimeInterval end = MRIOSRecordedDurationSeconds(recorder);
|
|
327
|
+
NSTimeInterval start = recorder.pauseStartedAtSeconds;
|
|
328
|
+
recorder.pauseStartedAtSeconds = -1.0;
|
|
329
|
+
if (end <= start) return;
|
|
330
|
+
[recorder.pauseRanges addObject:@{
|
|
331
|
+
@"start": @(start),
|
|
332
|
+
@"end": @(end)
|
|
333
|
+
}];
|
|
334
|
+
MRLog(@"▶️ iPhone capture resume marker at %.3f seconds (trim %.3f seconds)",
|
|
335
|
+
end, end - start);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
static NSString *MRIOSNextSegmentPath(MRIOSDeviceRecorder *recorder) {
|
|
339
|
+
NSUInteger segmentIndex = recorder.nextSegmentIndex;
|
|
340
|
+
recorder.nextSegmentIndex += 1;
|
|
341
|
+
// A single continuously captured iPhone movie does not need an intermediate
|
|
342
|
+
// filename. More importantly, some CoreMediaIO muxed devices reject a
|
|
343
|
+
// re-targeted AVCaptureMovieFileOutput URL even though the same session can
|
|
344
|
+
// record to the original requested path (the pre-segmentation behavior).
|
|
345
|
+
if (segmentIndex == 0) return recorder.outputPath;
|
|
346
|
+
NSString *base = [recorder.outputPath stringByDeletingPathExtension];
|
|
347
|
+
NSString *path = [NSString stringWithFormat:@"%@.iphone-part-%03lu.mov",
|
|
348
|
+
base, (unsigned long)segmentIndex];
|
|
349
|
+
return path;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **errorOut) {
|
|
353
|
+
NSString *segmentPath = MRIOSNextSegmentPath(recorder);
|
|
354
|
+
[[NSFileManager defaultManager] removeItemAtPath:segmentPath error:nil];
|
|
355
|
+
recorder.currentSegmentPath = segmentPath;
|
|
356
|
+
recorder.segmentStartCompleted = NO;
|
|
357
|
+
recorder.segmentFinishCompleted = NO;
|
|
358
|
+
recorder.segmentFinishError = nil;
|
|
359
|
+
recorder.recording = NO;
|
|
360
|
+
|
|
361
|
+
[recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:segmentPath]
|
|
362
|
+
recordingDelegate:recorder];
|
|
363
|
+
// AVCaptureMovieFileOutput can begin writing before its delegate callback
|
|
364
|
+
// is delivered. That callback may be queued behind Electron's synchronous
|
|
365
|
+
// native call, so requiring only the callback creates a false timeout even
|
|
366
|
+
// though real frames are already reaching the file. Recorded duration is
|
|
367
|
+
// an independent, frame-backed confirmation and is safe to use as the
|
|
368
|
+
// fallback start signal.
|
|
369
|
+
BOOL startObserved = MRWaitForFlag(^bool{
|
|
370
|
+
return recorder.segmentStartCompleted ||
|
|
371
|
+
recorder.segmentFinishCompleted ||
|
|
372
|
+
MRIOSHasProducedMedia(recorder);
|
|
373
|
+
}, 10.0);
|
|
374
|
+
if (!recorder.segmentStartCompleted &&
|
|
375
|
+
!recorder.segmentFinishCompleted &&
|
|
376
|
+
MRIOSHasProducedMedia(recorder)) {
|
|
377
|
+
MRIOSMarkSegmentStarted(recorder, NO);
|
|
378
|
+
}
|
|
379
|
+
BOOL started = startObserved && recorder.segmentStartCompleted &&
|
|
380
|
+
recorder.movieOutput.isRecording && !recorder.segmentFinishCompleted;
|
|
381
|
+
if (!started) {
|
|
382
|
+
CMTime duration = recorder.movieOutput.recordedDuration;
|
|
383
|
+
double durationSeconds = CMTIME_IS_NUMERIC(duration)
|
|
384
|
+
? MAX(0.0, CMTimeGetSeconds(duration))
|
|
385
|
+
: 0.0;
|
|
386
|
+
MRLog(@"❌ iPhone segment start timed out (sessionRunning=%@, outputRecording=%@, mediaDuration=%.3f, suspended=%@, connections=%lu)",
|
|
387
|
+
recorder.session.isRunning ? @"YES" : @"NO",
|
|
388
|
+
recorder.movieOutput.isRecording ? @"YES" : @"NO",
|
|
389
|
+
durationSeconds,
|
|
390
|
+
recorder.deviceInput.device.isSuspended ? @"YES" : @"NO",
|
|
391
|
+
(unsigned long)recorder.movieOutput.connections.count);
|
|
392
|
+
if (errorOut) {
|
|
393
|
+
BOOL connectedButNoFrames = recorder.session.isRunning &&
|
|
394
|
+
recorder.movieOutput.isRecording && durationSeconds <= 0.0;
|
|
395
|
+
*errorOut = recorder.segmentFinishError ?: (connectedButNoFrames
|
|
396
|
+
? MRIOSNoFramesError()
|
|
397
|
+
: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
398
|
+
code:11
|
|
399
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for an iPhone recording segment"}]);
|
|
400
|
+
}
|
|
401
|
+
return NO;
|
|
402
|
+
}
|
|
403
|
+
[recorder.segmentPaths addObject:segmentPath];
|
|
404
|
+
return YES;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
static BOOL MRIOSStopCurrentSegment(MRIOSDeviceRecorder *recorder) {
|
|
408
|
+
if (recorder.movieOutput.isRecording) {
|
|
409
|
+
[recorder.movieOutput stopRecording];
|
|
410
|
+
}
|
|
411
|
+
if (recorder.segmentStartCompleted && !recorder.segmentFinishCompleted) {
|
|
412
|
+
if (!MRWaitForFlag(^bool{ return recorder.segmentFinishCompleted; }, 20.0)) {
|
|
413
|
+
MRLog(@"⚠️ iPhone segment is still finalizing");
|
|
414
|
+
return NO;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!recorder.segmentStartCompleted) return YES;
|
|
418
|
+
BOOL exists = recorder.currentSegmentPath.length > 0 &&
|
|
419
|
+
[[NSFileManager defaultManager] fileExistsAtPath:recorder.currentSegmentPath];
|
|
420
|
+
return recorder.segmentFinishCompleted &&
|
|
421
|
+
MRIOSFileOutputSucceeded(recorder.segmentFinishError) && exists;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
static BOOL MRIOSAssembleSegments(MRIOSDeviceRecorder *recorder, NSError **errorOut) {
|
|
425
|
+
NSArray<NSString *> *segments = recorder.segmentPaths ?: @[];
|
|
426
|
+
if (segments.count == 0 || recorder.outputPath.length == 0) {
|
|
427
|
+
if (errorOut) {
|
|
428
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
429
|
+
code:12
|
|
430
|
+
userInfo:@{NSLocalizedDescriptionKey: @"No iPhone recording segments were produced"}];
|
|
431
|
+
}
|
|
432
|
+
return NO;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
436
|
+
NSArray<NSDictionary<NSString *, NSNumber *> *> *pauseRanges = recorder.pauseRanges ?: @[];
|
|
437
|
+
if (segments.count == 1 && pauseRanges.count == 0) {
|
|
438
|
+
if ([segments.firstObject isEqualToString:recorder.outputPath]) {
|
|
439
|
+
BOOL exists = [fileManager fileExistsAtPath:recorder.outputPath];
|
|
440
|
+
if (exists) MRLog(@"✅ iPhone recording finalized in its destination path");
|
|
441
|
+
return exists;
|
|
442
|
+
}
|
|
443
|
+
[fileManager removeItemAtPath:recorder.outputPath error:nil];
|
|
444
|
+
BOOL moved = [fileManager moveItemAtPath:segments.firstObject
|
|
445
|
+
toPath:recorder.outputPath
|
|
446
|
+
error:errorOut];
|
|
447
|
+
if (moved) MRLog(@"✅ iPhone recording finalized without a pause merge");
|
|
448
|
+
return moved;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
AVMutableComposition *composition = [AVMutableComposition composition];
|
|
452
|
+
CMTime insertionTime = kCMTimeZero;
|
|
453
|
+
for (NSUInteger segmentIndex = 0; segmentIndex < segments.count; segmentIndex++) {
|
|
454
|
+
NSString *segmentPath = segments[segmentIndex];
|
|
455
|
+
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[NSURL fileURLWithPath:segmentPath]
|
|
456
|
+
options:nil];
|
|
457
|
+
CMTime duration = asset.duration;
|
|
458
|
+
if (!CMTIME_IS_NUMERIC(duration) || CMTIME_COMPARE_INLINE(duration, <=, kCMTimeZero)) {
|
|
459
|
+
if (errorOut) {
|
|
460
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
461
|
+
code:13
|
|
462
|
+
userInfo:@{NSLocalizedDescriptionKey: @"An iPhone recording segment has no playable duration"}];
|
|
463
|
+
}
|
|
464
|
+
return NO;
|
|
465
|
+
}
|
|
466
|
+
// New recordings always contain one continuously written iPhone movie.
|
|
467
|
+
// Keeping AVCaptureMovieFileOutput alive across pause avoids re-arming
|
|
468
|
+
// the fragile USB muxed compressor. Remove paused ranges here instead.
|
|
469
|
+
if (segments.count == 1 && pauseRanges.count > 0) {
|
|
470
|
+
NSTimeInterval assetDuration = CMTimeGetSeconds(duration);
|
|
471
|
+
NSTimeInterval sourceCursor = 0.0;
|
|
472
|
+
int32_t timeScale = duration.timescale > 0 ? duration.timescale : 600;
|
|
473
|
+
for (NSDictionary<NSString *, NSNumber *> *range in pauseRanges) {
|
|
474
|
+
NSTimeInterval pauseStart = MIN(assetDuration,
|
|
475
|
+
MAX(sourceCursor, range[@"start"].doubleValue));
|
|
476
|
+
NSTimeInterval pauseEnd = MIN(assetDuration,
|
|
477
|
+
MAX(pauseStart, range[@"end"].doubleValue));
|
|
478
|
+
if (pauseStart > sourceCursor) {
|
|
479
|
+
CMTime sourceStart = CMTimeMakeWithSeconds(sourceCursor, timeScale);
|
|
480
|
+
CMTime keptDuration = CMTimeMakeWithSeconds(pauseStart - sourceCursor, timeScale);
|
|
481
|
+
NSError *insertError = nil;
|
|
482
|
+
if (![composition insertTimeRange:CMTimeRangeMake(sourceStart, keptDuration)
|
|
483
|
+
ofAsset:asset
|
|
484
|
+
atTime:insertionTime
|
|
485
|
+
error:&insertError]) {
|
|
486
|
+
if (errorOut) *errorOut = insertError;
|
|
487
|
+
return NO;
|
|
488
|
+
}
|
|
489
|
+
insertionTime = CMTimeAdd(insertionTime, keptDuration);
|
|
490
|
+
}
|
|
491
|
+
sourceCursor = MAX(sourceCursor, pauseEnd);
|
|
492
|
+
}
|
|
493
|
+
if (assetDuration > sourceCursor) {
|
|
494
|
+
CMTime sourceStart = CMTimeMakeWithSeconds(sourceCursor, timeScale);
|
|
495
|
+
CMTime keptDuration = CMTimeMakeWithSeconds(assetDuration - sourceCursor, timeScale);
|
|
496
|
+
NSError *insertError = nil;
|
|
497
|
+
if (![composition insertTimeRange:CMTimeRangeMake(sourceStart, keptDuration)
|
|
498
|
+
ofAsset:asset
|
|
499
|
+
atTime:insertionTime
|
|
500
|
+
error:&insertError]) {
|
|
501
|
+
if (errorOut) *errorOut = insertError;
|
|
502
|
+
return NO;
|
|
503
|
+
}
|
|
504
|
+
insertionTime = CMTimeAdd(insertionTime, keptDuration);
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
NSError *insertError = nil;
|
|
508
|
+
if (![composition insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration)
|
|
509
|
+
ofAsset:asset
|
|
510
|
+
atTime:insertionTime
|
|
511
|
+
error:&insertError]) {
|
|
512
|
+
if (errorOut) *errorOut = insertError;
|
|
513
|
+
return NO;
|
|
514
|
+
}
|
|
515
|
+
insertionTime = CMTimeAdd(insertionTime, duration);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (CMTIME_COMPARE_INLINE(insertionTime, <=, kCMTimeZero)) {
|
|
519
|
+
if (errorOut) {
|
|
520
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
521
|
+
code:16
|
|
522
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone recording contains no unpaused media"}];
|
|
523
|
+
}
|
|
524
|
+
return NO;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
AVAssetExportSession *exporter = [[AVAssetExportSession alloc]
|
|
528
|
+
initWithAsset:composition presetName:AVAssetExportPresetPassthrough];
|
|
529
|
+
if (!exporter) {
|
|
530
|
+
if (errorOut) {
|
|
531
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
532
|
+
code:14
|
|
533
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone recording segments could not be joined"}];
|
|
534
|
+
}
|
|
535
|
+
return NO;
|
|
536
|
+
}
|
|
537
|
+
NSString *assembledPath = [[recorder.outputPath stringByDeletingPathExtension]
|
|
538
|
+
stringByAppendingString:@".iphone-assembled.mov"];
|
|
539
|
+
[fileManager removeItemAtPath:assembledPath error:nil];
|
|
540
|
+
exporter.outputURL = [NSURL fileURLWithPath:assembledPath];
|
|
541
|
+
exporter.outputFileType = AVFileTypeQuickTimeMovie;
|
|
542
|
+
exporter.shouldOptimizeForNetworkUse = NO;
|
|
543
|
+
|
|
544
|
+
__block volatile BOOL exportFinished = NO;
|
|
545
|
+
[exporter exportAsynchronouslyWithCompletionHandler:^{ exportFinished = YES; }];
|
|
546
|
+
BOOL completedInTime = MRWaitForFlag(^bool{ return exportFinished; }, 60.0);
|
|
547
|
+
if (!completedInTime) [exporter cancelExport];
|
|
548
|
+
BOOL completed = completedInTime && exporter.status == AVAssetExportSessionStatusCompleted;
|
|
549
|
+
NSError *exportError = [[exporter.error retain] autorelease];
|
|
550
|
+
if (!completed && errorOut) {
|
|
551
|
+
*errorOut = exportError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
552
|
+
code:15
|
|
553
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out while joining iPhone recording segments"}];
|
|
554
|
+
}
|
|
555
|
+
[exporter release];
|
|
556
|
+
if (!completed) {
|
|
557
|
+
[fileManager removeItemAtPath:assembledPath error:nil];
|
|
558
|
+
return NO;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
[fileManager removeItemAtPath:recorder.outputPath error:nil];
|
|
562
|
+
NSError *moveError = nil;
|
|
563
|
+
if (![fileManager moveItemAtPath:assembledPath
|
|
564
|
+
toPath:recorder.outputPath
|
|
565
|
+
error:&moveError]) {
|
|
566
|
+
if (errorOut) *errorOut = moveError;
|
|
567
|
+
return NO;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
for (NSString *segmentPath in segments) {
|
|
571
|
+
if (![segmentPath isEqualToString:recorder.outputPath]) {
|
|
572
|
+
[fileManager removeItemAtPath:segmentPath error:nil];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (pauseRanges.count > 0) {
|
|
576
|
+
MRLog(@"✅ Removed %lu paused range(s) from the iPhone recording",
|
|
577
|
+
(unsigned long)pauseRanges.count);
|
|
578
|
+
} else {
|
|
579
|
+
MRLog(@"✅ Joined %lu iPhone recording segments", (unsigned long)segments.count);
|
|
580
|
+
}
|
|
581
|
+
return YES;
|
|
582
|
+
}
|
|
583
|
+
|
|
164
584
|
extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
|
|
165
585
|
NSMutableArray<NSDictionary *> *devices = [NSMutableArray array];
|
|
166
|
-
|
|
586
|
+
// Device listing is called by the renderer's lightweight monitor. Return a
|
|
587
|
+
// snapshot immediately; the start path still gets a bounded readiness wait.
|
|
588
|
+
for (AVCaptureDevice *device in MRIOSCaptureDevices(0.0)) {
|
|
167
589
|
CMVideoDimensions largest = {0, 0};
|
|
168
590
|
for (AVCaptureDeviceFormat *format in device.formats) {
|
|
169
591
|
CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription);
|
|
@@ -179,12 +601,16 @@ extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
|
|
|
179
601
|
@"model": device.modelID ?: @"",
|
|
180
602
|
@"connected": @(device.isConnected),
|
|
181
603
|
@"suspended": @(device.isSuspended),
|
|
604
|
+
@"captureReady": @(!device.isSuspended && device.isConnected),
|
|
182
605
|
@"width": @(largest.width),
|
|
183
606
|
@"height": @(largest.height),
|
|
184
607
|
@"hasAudio": @YES,
|
|
185
608
|
@"transport": @"usb"
|
|
186
609
|
}];
|
|
187
610
|
}
|
|
611
|
+
if (devices.count == 0) {
|
|
612
|
+
[devices addObjectsFromArray:MRUSBConnectedIOSDevices()];
|
|
613
|
+
}
|
|
188
614
|
return devices;
|
|
189
615
|
}
|
|
190
616
|
|
|
@@ -252,6 +678,16 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
252
678
|
recorder.startCompleted = NO;
|
|
253
679
|
recorder.finishCompleted = NO;
|
|
254
680
|
recorder.finishError = nil;
|
|
681
|
+
recorder.paused = NO;
|
|
682
|
+
recorder.segmentStartCompleted = NO;
|
|
683
|
+
recorder.segmentFinishCompleted = NO;
|
|
684
|
+
recorder.segmentFinishError = nil;
|
|
685
|
+
recorder.segmentPaths = [NSMutableArray array];
|
|
686
|
+
recorder.currentSegmentPath = nil;
|
|
687
|
+
recorder.nextSegmentIndex = 0;
|
|
688
|
+
recorder.segmentFailure = NO;
|
|
689
|
+
recorder.pauseRanges = [NSMutableArray array];
|
|
690
|
+
recorder.pauseStartedAtSeconds = -1.0;
|
|
255
691
|
recorder.captureCamera = captureCamera;
|
|
256
692
|
recorder.captureMicrophone = captureMicrophone;
|
|
257
693
|
recorder.cameraOutputPath = cameraOutputPath;
|
|
@@ -348,13 +784,10 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
348
784
|
}
|
|
349
785
|
|
|
350
786
|
recorder.startRequested = YES;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}, 10.0);
|
|
356
|
-
if (!started || !recorder.startCompleted || recorder.finishCompleted) {
|
|
357
|
-
if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
|
|
787
|
+
NSError *segmentError = nil;
|
|
788
|
+
if (!MRIOSStartNextSegment(recorder, &segmentError)) {
|
|
789
|
+
NSError *reportedSegmentError = [[segmentError retain] autorelease];
|
|
790
|
+
MRIOSStopCurrentSegment(recorder);
|
|
358
791
|
[recorder.session stopRunning];
|
|
359
792
|
if (isCameraRecording()) stopCameraRecording();
|
|
360
793
|
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
@@ -362,16 +795,15 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
362
795
|
MRSyncConfigure(NO);
|
|
363
796
|
stopIOSDeviceRecording();
|
|
364
797
|
if (errorOut) {
|
|
365
|
-
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
366
|
-
|
|
367
|
-
|
|
798
|
+
*errorOut = reportedSegmentError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
799
|
+
code:6
|
|
800
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for the first iPhone frame"}];
|
|
368
801
|
}
|
|
369
802
|
return false;
|
|
370
803
|
}
|
|
371
804
|
if (captureCamera && !waitForCameraRecordingStart(8.0)) {
|
|
372
805
|
MRLog(@"❌ Camera did not produce a synchronized frame for iPhone recording");
|
|
373
|
-
|
|
374
|
-
MRWaitForFlag(^bool{ return recorder.finishCompleted; }, 10.0);
|
|
806
|
+
MRIOSStopCurrentSegment(recorder);
|
|
375
807
|
[recorder.session stopRunning];
|
|
376
808
|
if (isCameraRecording()) stopCameraRecording();
|
|
377
809
|
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
@@ -412,6 +844,9 @@ extern "C" bool stopIOSDeviceRecording(void) {
|
|
|
412
844
|
-[recorder.primaryStartedAt timeIntervalSinceNow] - MRSyncGetPausedDurationSeconds());
|
|
413
845
|
MRSyncSetStopLimitSeconds(duration);
|
|
414
846
|
}
|
|
847
|
+
if (recorder.paused) {
|
|
848
|
+
MRIOSEndPauseRange(recorder);
|
|
849
|
+
}
|
|
415
850
|
|
|
416
851
|
BOOL cameraStopped = YES;
|
|
417
852
|
BOOL microphoneStopped = YES;
|
|
@@ -422,30 +857,60 @@ extern "C" bool stopIOSDeviceRecording(void) {
|
|
|
422
857
|
microphoneStopped = stopStandaloneAudioRecording();
|
|
423
858
|
}
|
|
424
859
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
NSLog(@"[Recorder] iPhone is still finalizing; retaining the session");
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
860
|
+
BOOL primaryStopped = MRIOSStopCurrentSegment(recorder);
|
|
861
|
+
if (!primaryStopped && recorder.segmentStartCompleted &&
|
|
862
|
+
!recorder.segmentFinishCompleted) {
|
|
863
|
+
// Keep ownership while AVCaptureMovieFileOutput still owns the
|
|
864
|
+
// segment so a later stop retry cannot race its delegate callback.
|
|
865
|
+
NSLog(@"[Recorder] iPhone is still finalizing; retaining the session");
|
|
866
|
+
return false;
|
|
435
867
|
}
|
|
436
868
|
if (recorder.session.isRunning) [recorder.session stopRunning];
|
|
437
869
|
|
|
438
|
-
NSError *
|
|
439
|
-
BOOL
|
|
870
|
+
NSError *assemblyError = nil;
|
|
871
|
+
BOOL recordingSucceeded = primaryStopped && !recorder.segmentFailure;
|
|
872
|
+
BOOL assembled = recordingSucceeded && MRIOSAssembleSegments(recorder, &assemblyError);
|
|
873
|
+
if (!assembled && recorder.startCompleted) {
|
|
874
|
+
MRLog(@"❌ iPhone recording assembly failed: %@",
|
|
875
|
+
assemblyError.localizedDescription ?: @"Unknown segment error");
|
|
876
|
+
}
|
|
877
|
+
BOOL finished = !recorder.startCompleted || assembled;
|
|
440
878
|
BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:recorder.outputPath];
|
|
441
879
|
MRSyncConfigurePrimaryStart(NO);
|
|
442
880
|
MRSyncConfigure(NO);
|
|
443
|
-
g_iosRecorder = nil;
|
|
444
881
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
882
|
+
// AVCaptureSession keeps its input (and therefore the USB screen
|
|
883
|
+
// device) retained after stopRunning. Detach everything before dropping
|
|
884
|
+
// the recorder so the same iPhone is discoverable on the next attempt.
|
|
885
|
+
@try {
|
|
886
|
+
[recorder.session beginConfiguration];
|
|
887
|
+
if (recorder.movieOutput && [recorder.session.outputs containsObject:recorder.movieOutput]) {
|
|
888
|
+
[recorder.session removeOutput:recorder.movieOutput];
|
|
889
|
+
}
|
|
890
|
+
if (recorder.deviceInput && [recorder.session.inputs containsObject:recorder.deviceInput]) {
|
|
891
|
+
[recorder.session removeInput:recorder.deviceInput];
|
|
892
|
+
}
|
|
893
|
+
[recorder.session commitConfiguration];
|
|
894
|
+
} @catch (NSException *exception) {
|
|
895
|
+
MRLog(@"⚠️ iPhone capture teardown warning: %@", exception.reason);
|
|
448
896
|
}
|
|
897
|
+
recorder.movieOutput = nil;
|
|
898
|
+
recorder.deviceInput = nil;
|
|
899
|
+
recorder.session = nil;
|
|
900
|
+
recorder.finishError = nil;
|
|
901
|
+
recorder.segmentFinishError = nil;
|
|
902
|
+
recorder.segmentPaths = nil;
|
|
903
|
+
recorder.pauseRanges = nil;
|
|
904
|
+
recorder.pauseStartedAtSeconds = -1.0;
|
|
905
|
+
recorder.currentSegmentPath = nil;
|
|
906
|
+
recorder.outputPath = nil;
|
|
907
|
+
recorder.cameraOutputPath = nil;
|
|
908
|
+
recorder.audioOutputPath = nil;
|
|
909
|
+
recorder.primaryStartedAt = nil;
|
|
910
|
+
g_iosRecorder = nil;
|
|
911
|
+
[recorder release];
|
|
912
|
+
|
|
913
|
+
if (!recordingSucceeded) return false;
|
|
449
914
|
if (!cameraStopped || !microphoneStopped) {
|
|
450
915
|
MRLog(@"⚠️ iPhone recording finalized, but an auxiliary camera/microphone writer reported a stop error");
|
|
451
916
|
}
|
|
@@ -469,19 +934,26 @@ extern "C" bool isIOSDeviceRecordingStopping(void) {
|
|
|
469
934
|
}
|
|
470
935
|
|
|
471
936
|
extern "C" bool isIOSDeviceRecording(void) {
|
|
472
|
-
return g_iosRecorder &&
|
|
937
|
+
return g_iosRecorder && !g_iosRecorder.stopRequested &&
|
|
938
|
+
(g_iosRecorder.recording || g_iosRecorder.paused || g_iosRecorder.movieOutput.isRecording);
|
|
473
939
|
}
|
|
474
940
|
|
|
475
941
|
extern "C" bool pauseIOSDeviceRecording(void) {
|
|
476
942
|
@autoreleasepool {
|
|
477
943
|
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
478
|
-
if (!recorder
|
|
479
|
-
if (recorder.
|
|
944
|
+
if (!recorder) return false;
|
|
945
|
+
if (recorder.paused) return true;
|
|
946
|
+
if (!recorder.movieOutput.isRecording) return false;
|
|
480
947
|
@try {
|
|
481
|
-
|
|
948
|
+
// Do not stop or pause AVCaptureMovieFileOutput here. USB iPhone
|
|
949
|
+
// muxed sources may fail to re-arm their compressor on resume.
|
|
950
|
+
// Capture continuously and trim this time range during finalization.
|
|
951
|
+
MRIOSBeginPauseRange(recorder);
|
|
482
952
|
MRSyncPause();
|
|
953
|
+
recorder.paused = YES;
|
|
483
954
|
return true;
|
|
484
955
|
} @catch (NSException *exception) {
|
|
956
|
+
MRSyncResume();
|
|
485
957
|
MRLog(@"❌ iPhone pause failed: %@", exception.reason);
|
|
486
958
|
return false;
|
|
487
959
|
}
|
|
@@ -491,13 +963,12 @@ extern "C" bool pauseIOSDeviceRecording(void) {
|
|
|
491
963
|
extern "C" bool resumeIOSDeviceRecording(void) {
|
|
492
964
|
@autoreleasepool {
|
|
493
965
|
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
494
|
-
if (!recorder
|
|
495
|
-
if (!recorder.movieOutput.
|
|
496
|
-
|
|
497
|
-
return true;
|
|
498
|
-
}
|
|
966
|
+
if (!recorder) return false;
|
|
967
|
+
if (!recorder.paused) return recorder.movieOutput.isRecording;
|
|
968
|
+
if (!recorder.movieOutput.isRecording) return false;
|
|
499
969
|
@try {
|
|
500
|
-
|
|
970
|
+
MRIOSEndPauseRange(recorder);
|
|
971
|
+
recorder.paused = NO;
|
|
501
972
|
MRSyncResume();
|
|
502
973
|
return true;
|
|
503
974
|
} @catch (NSException *exception) {
|
|
@@ -526,6 +997,7 @@ Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
|
|
|
526
997
|
item.Set("model", Napi::String::New(env, [device[@"model"] UTF8String]));
|
|
527
998
|
item.Set("connected", Napi::Boolean::New(env, [device[@"connected"] boolValue]));
|
|
528
999
|
item.Set("suspended", Napi::Boolean::New(env, [device[@"suspended"] boolValue]));
|
|
1000
|
+
item.Set("captureReady", Napi::Boolean::New(env, [device[@"captureReady"] boolValue]));
|
|
529
1001
|
item.Set("width", Napi::Number::New(env, [device[@"width"] intValue]));
|
|
530
1002
|
item.Set("height", Napi::Number::New(env, [device[@"height"] intValue]));
|
|
531
1003
|
item.Set("hasAudio", Napi::Boolean::New(env, true));
|
|
@@ -618,7 +1090,7 @@ Napi::Value GetIOSDeviceRecordingStatus(const Napi::CallbackInfo& info) {
|
|
|
618
1090
|
Napi::Object status = Napi::Object::New(info.Env());
|
|
619
1091
|
status.Set("isRecording", Napi::Boolean::New(info.Env(), isIOSDeviceRecording()));
|
|
620
1092
|
status.Set("isPaused", Napi::Boolean::New(info.Env(),
|
|
621
|
-
g_iosRecorder && g_iosRecorder.
|
|
1093
|
+
g_iosRecorder && g_iosRecorder.paused));
|
|
622
1094
|
NSString *path = currentIOSDeviceRecordingPath();
|
|
623
1095
|
if (path.length > 0) status.Set("outputPath", Napi::String::New(info.Env(), [path UTF8String]));
|
|
624
1096
|
return status;
|